GuidesOneMux

Node.js OpenAI SDK 애플리케이션 마이그레이션: Base URL, 스트리밍 및 구조화된 출력

Node.js 개발자를 위한 OpenAI SDK 마이그레이션 기술 가이드: 커스텀 Base URL 설정, 인증 관리, 스트리밍 구조화된 출력 처리 및 API 에러 진단 방법을 다룹니다.

목차

소개

공식 openai SDK를 사용하는 기존 Node.js 애플리케이션을 마이그레이션할 때 비즈니스 로직을 처음부터 다시 작성할 필요는 없습니다. 시중의 여러 통합 모델 공급자가 OpenAI REST 규격을 엄격하게 준수하므로, OneMux와 같은 OpenAI 호환 라우팅 레이어로 애플리케이션을 전환하려면 클라이언트 초기화 파라미터만 간단히 변경하면 됩니다.

그러나 프로덕션 환경의 파이프라인은 데이터 추출을 위한 엄격한 스키마 준수, 토큰 단위 스트리밍, 견고한 에러 복구 메커니즘 등 고급 기능을 요구하는 경우가 많습니다. 본 가이드에서는 Node.js OpenAI SDK의 설정을 재구성하고, 구조화된 JSON Schema 추출을 실행하며, 스트리밍 응답을 안정적으로 처리하고, 실행 에러를 진단하는 방법을 살펴봅니다.


1단계: Base URL 및 인증 설정

공식 OpenAI Node.js SDK(openai v4 이상)는 OpenAI 클래스 생성자 내부에서 직접 설정 재정의를 지원합니다. 전송 레이어를 OpenAI 호환 엔드포인트로 마이그레이션하려면 baseURL 프로퍼티를 지정하고 해당 API 키를 설정하세요.

SDK 클라이언트 초기화

import OpenAI from "openai";

// Initialize the client pointing to an OpenAI-compatible gateway
const client = new OpenAI({
  apiKey: process.env.ONEMUX_API_KEY,
  baseURL: "https://api.onemux.net/v1",
});

마이그레이션 사전 점검 사항

  1. 환경 변수: 공급자별 키를 .env 구성에서 명확히 분리하세요(예: ONEMUX_API_KEY와 OPENAI_API_KEY 분리).
  2. 경로 접미사(Path Suffix): OpenAI SDK는 baseURL 뒤에 /chat/completions와 같은 라우트 경로를 자동으로 덧붙입니다. 게이트웨이가 루트 라우팅을 별도로 처리하지 않는 한, baseURL에 반드시 /v1 접미사(예: https://api.onemux.net/v1)가 포함되어 있는지 확인하세요.
  3. SDK 버전: openai 버전이 ^4.0.0 이상인지 확인하세요. 구버전인 v3는 설정 구조(Configuration 클래스)가 다릅니다.

지원되는 라우팅 규칙을 검토하고 API 자격 증명을 발급받으려면 OneMux 빠른 시작(Quickstart) 문서를 확인하세요.


2단계: 엄격한 JSON Schema를 활용한 구조화된 데이터 추출

구조화된 출력(Structured Outputs)을 사용하면 모델이 제공된 JSON Schema와 완벽하게 일치하는 형식으로만 결과를 반환하도록 강제할 수 있습니다. OpenAI의 구조화된 출력 관련 공식 문서에 따르면, "strict": true를 적용할 때 다음 규칙을 충족해야 합니다.

  • 모든 객체 필드가 required 배열에 선언되어 있어야 합니다.
  • 모든 객체 스키마에 additionalProperties: false가 명시적으로 정의되어야 합니다.
  • 선택적(Optional) 필드는 null과의 유니언 타입으로 모델링되어야 합니다(예: ["string", "null"]).

표준 구조화된 완성 구현

import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.ONEMUX_API_KEY,
  baseURL: "https://api.onemux.net/v1",
});

// Define strict JSON Schema for invoice extraction
const invoiceExtractionSchema = {
  type: "object",
  properties: {
    vendorName: { type: "string" },
    invoiceId: { type: "string" },
    totalAmount: { type: "number" },
    currency: { type: "string" },
    items: {
      type: "array",
      items: {
        type: "object",
        properties: {
          description: { type: "string" },
          quantity: { type: "number" },
          unitPrice: { type: "number" },
        },
        required: ["description", "quantity", "unitPrice"],
        additionalProperties: false,
      },
    },
  },
  required: ["vendorName", "invoiceId", "totalAmount", "currency", "items"],
  additionalProperties: false,
} as const;

async function extractInvoice(rawDocumentText: string) {
  const response = await client.chat.completions.create({
    model: "gpt-4o",
    messages: [
      {
        role: "system",
        content: "Extract normalized invoice records from the provided unstructured input.",
      },
      {
        role: "user",
        content: rawDocumentText,
      },
    ],
    response_format: {
      type: "json_schema",
      json_schema: {
        name: "invoice_extraction",
        strict: true,
        schema: invoiceExtractionSchema,
      },
    },
  });

  const choice = response.choices[0];

  // Check for safety or policy refusals
  if (choice.message.refusal) {
    throw new Error(`Model refused extraction: ${choice.message.refusal}`);
  }

  const rawJson = choice.message.content;
  if (!rawJson) {
    throw new Error("Received empty response payload");
  }

  return JSON.parse(rawJson);
}

다운스트림 게이트웨이와의 호환성은 기반 모델 엔드포인트가 strict: true가 적용된 json_schema 모드를 지원하는지에 따라 달라집니다. 플랫폼별 모델 기능에 대한 자세한 내용은 OneMux 모델 카탈로그에서 확인할 수 있습니다.


3단계: 구조화된 출력에서 스트리밍 처리하기

response_format과 함께 stream: true를 활성화하면 모델은 JSON 페이로드의 부분 문자열 조각들을 순차적으로 스트리밍합니다. 일반 텍스트 채팅과 달리, 개별 청크는 불완전한 JSON 토큰 상태이므로 각각을 직접 JSON.parse()로 파싱할 수 없습니다.

스트리밍된 JSON 청크 누적하기

구조화된 출력을 올바르게 스트리밍하려면 스트림이 완료될 때까지 델타 조각들을 메모리에 누적하거나, 유입되는 청크를 증분 JSON 스트림 파서(incremental JSON stream parser)로 전달해야 합니다.

async function streamInvoiceExtraction(rawDocumentText: string) {
  const stream = await client.chat.completions.create({
    model: "gpt-4o",
    stream: true,
    messages: [
      {
        role: "system",
        content: "Extract normalized invoice records from the provided text.",
      },
      {
        role: "user",
        content: rawDocumentText,
      },
    ],
    response_format: {
      type: "json_schema",
      json_schema: {
        name: "invoice_extraction",
        strict: true,
        schema: invoiceExtractionSchema,
      },
    },
  });

  let accumulatedJson = "";
  let refusalReason: string | null = null;

  for await (const chunk of stream) {
    const delta = chunk.choices[0]?.delta;

    if (delta?.refusal) {
      refusalReason = (refusalReason || "") + delta.refusal;
    }

    if (delta?.content) {
      accumulatedJson += delta.content;
      // Emit partial events or log progress if building a reactive UI
    }
  }

  if (refusalReason) {
    throw new Error(`Extraction was refused during stream: ${refusalReason}`);
  }

  try {
    return JSON.parse(accumulatedJson);
  } catch (err) {
    throw new Error(`Failed to parse streamed JSON payload: ${accumulatedJson}`);
  }
}

사용자 인터페이스의 최초 응답 시간(TTFB)을 단축하기 위해 스트리밍을 사용하는 경우라면, 네트워크 프록시를 통해 다운스트림 엔드포인트가 지속적인 Server-Sent Events(SSE)를 정상적으로 지원하는지 점검하세요.


4단계: 에러 진단 및 문제 해결

SDK 호출 대상을 커스텀 baseURL로 전환할 때는 에러의 원인이 네트워크 프록시 문제인지, 스키마 설정 오류인지, 처리율 제한(Rate Limit)인지, 모델의 자체 거부(Refusal)인지 명확하게 구분해 진단해야 합니다.

주요 에러 및 해결 전략

에러 코드 / 타입근본 원인진단 및 해결 방법
401 UnauthorizedAPI 키 누락 또는 유효하지 않음대상 게이트웨이에서 인식할 수 있는 유효한 자격 증명이 apiKey에 설정되었는지 확인하세요(예: ONEMUX_API_KEY 점검).
400 BadRequestError스키마 위반 (strict: true)에러 메시지에서 지원되지 않는 JSON Schema 키워드가 있는지 확인하세요(예: additionalProperties: false 누락 또는 required 필드 누락).
404 NotFound잘못된 경로 또는 유효하지 않은 모델 IDbaseURL 끝에 /v1이 포함되어 있는지 확인하세요. 요청한 모델이 공급자의 활성 모델 목록에 존재하는지 확인하세요.
429 RateLimitError동시 요청 수 또는 지출 한도 도달지수 백오프(Exponential Backoff)를 구현하세요. 공급자 대시보드에서 쿼터 한도를 모니터링하세요.
Refusal (Choice 본문)모델의 안전 가드레일 작동모델이 요청을 명시적으로 거부했습니다. content 대신 choice.message.refusal 값을 점검하세요.

구조화된 에러 처리 래퍼

import OpenAI from "openai";

async function safeExtractWrapper(fn: () => Promise<any>) {
  try {
    return await fn();
  } catch (error: any) {
    if (error instanceof OpenAI.APIError) {
      console.error(`OpenAI API Error: status=${error.status}, type=${error.type}`);
      console.error(`Message: ${error.message}`);

      if (error.status === 400) {
        console.error("Verify that your schema complies strictly with JSON Schema constraints.");
      } else if (error.status === 401) {
        console.error("Authentication failed: Check your bearer token and proxy baseURL.");
      } else if (error.status === 429) {
        console.warn("Rate limit reached. Queue request for delayed retry.");
      }
    } else {
      console.error("Unexpected application error:", error);
    }
    throw error;
  }
}

5단계: OneMux를 통한 공급자 통합

OpenAI SDK 요청을 OneMux로 라우팅하면 별도의 서드파티 라이브러리나 커스텀 프로토콜 어댑터를 작성하지 않고도 다중 모델 운영 환경을 간소화할 수 있습니다.

  • 통합 엔드포인트: 표준 OpenAI SDK 인터페이스를 그대로 유지하면서 다양한 모델로 자유롭게 요청을 라우팅할 수 있습니다.
  • 비용 관리 및 가시성: 여러 공급자의 청구서를 따로 관리할 필요 없이 결제를 일원화하고, 모델별 사용량을 검토하며, 지출 규칙을 중앙에서 제어할 수 있습니다.
  • 유연한 종량제(Pay-as-You-Go): 유연한 크레딧 충전을 통해 예기치 않은 서비스 중단을 방지할 수 있습니다.

현재 사용 요금을 확인하고 모델별 라우팅 옵션을 비교하려면 OneMux 요금제 안내를 참고하고, 기술 개요는 OneMux 공식 문서에서 확인하세요.


참고 자료

FAQ

커스텀 Base URL을 사용할 때도 OpenAI SDK에서 Zod를 그대로 쓸 수 있나요?

네, 가능합니다. 대상 게이트웨이가 `/chat/completions`의 구조화된 출력 사양을 완벽히 지원한다면, `client.beta.chat.completions.parse()` 및 `zodResponseFormat()` 같은 헬퍼 메서드가 Zod 스키마를 요구되는 엄격한 JSON Schema 페이로드로 올바르게 변환하여 원활히 작동합니다.

'strict: true' 설정 시 모델이 400 에러를 반환하는 이유는 무엇인가요?

OpenAI의 구조화된 출력 엔진은 모든 객체 정의에 `additionalProperties: false`가 명시적으로 선언되어야 하며, 정의된 모든 속성이 `required` 배열에 나열되어야 합니다. `required` 배열에서 누락된 키가 있거나 지원되지 않는 JSON Schema 키워드가 포함되어 있다면 API가 400 유효성 검사 에러를 반환합니다.

baseURL을 변경하면 SDK의 텔레메트리나 타임아웃 동작에도 영향이 있나요?

`baseURL`을 재정의하면 모든 HTTP 전송 대상이 새 URL로 리디렉션됩니다. OpenAI 생성자에 설정된 `maxRetries`나 `timeout`과 같은 타임아웃 및 재시도 옵션은 클라이언트 측에서 이전과 동일하게 동작합니다.

관련 글