스트리밍

SSE 로 토큰을 받는 동안 무엇이 오는가

/api/v1/chat/completions/api/v1/completions 뒤의 모든 모델은 스트리밍을 지원합니다. "stream": true 를 보내면 전체 응답을 기다리는 대신 토큰이 생성되는 대로 Server-Sent Events 로 도착합니다.

쓸 만한 상황

  • 채팅 UI — 답이 만들어지는 과정을 보여주는 편이 로딩 스피너보다 낫습니다.
  • 긴 응답 — 전체 소요 시간보다 첫 토큰까지의 시간이 더 중요할 때.
  • 중간에 끊을 수도 있는 요청 — 연결을 닫는 순간부터 토큰 과금이 멈춥니다(뒤의 "취소" 항목 참고).

추가 비용은 없습니다. 같은 모델이면 스트리밍이든 아니든 요금 계산 방식 의 같은 산식으로 청구됩니다. 실질적인 차이는 비용이 어디에 실리는가 뿐입니다 — 비스트리밍 응답은 X-MyIP-Cost-KRW 헤더에 싣지만, 스트리밍 응답은 그럴 수 없습니다. 헤더는 첫 바이트보다 먼저 나가는데 그 시점엔 비용을 아직 모르기 때문입니다. 대신 마지막 usage 청크에 실립니다.

최소 구현

typescript
const response = await fetch('https://openrouter.myip.co.kr/api/v1/chat/completions', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.MYIP_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model: 'google/gemma-4-26b-a4b',
    messages: [{ role: 'user', content: '부산 항구를 주제로 하이쿠를 하나 써줘.' }],
    stream: true,
  }),
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(error.message);
}

const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';

for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });

  let index: number;
  while ((index = buffer.indexOf('\n\n')) >= 0) {
    const frame = buffer.slice(0, index);
    buffer = buffer.slice(index + 2);
    if (!frame.startsWith('data:')) continue;

    const payload = frame.slice(5).trim();
    if (payload === '[DONE]') break;

    const chunk = JSON.parse(payload);
    if (chunk.error) throw new Error(chunk.error.message);

    process.stdout.write(chunk.choices?.[0]?.delta?.content ?? '');
    if (chunk.usage) console.log('\n비용(KRW):', chunk.usage.cost);
  }
}

처음 구현할 때 놓치기 쉬운 두 가지입니다.

  • \n 이 아니라 빈 줄(\n\n) 로만 자르세요. SSE 이벤트 하나가 네트워크 read 여러 번에 걸쳐 나뉠 수 있습니다. 위 코드처럼 아직 완결되지 않은 꼬리를 버퍼에 남겨두고 다음 청크 앞에 이어 붙이세요.
  • choices[0].delta 를 보기 전에 chunk.error 를 먼저 확인하세요. 스트림 도중의 실패는 이미 HTTP 200 이 나간 상태에서 오는 평범한 data: 이벤트입니다. 스트리밍 도중 오류 처리 를 보세요.

OpenAI SDK 를 쓰면 이 버퍼링을 대신 해줍니다.

import os
from openai import OpenAI

client = OpenAI(base_url="https://openrouter.myip.co.kr/api/v1", api_key=os.environ["MYIP_API_KEY"])

stream = client.chat.completions.create(
    model="google/gemma-4-26b-a4b",
    messages=[{"role": "user", "content": "부산 항구를 주제로 하이쿠를 하나 써줘."}],
    stream=True,
)

for chunk in stream:
    if chunk.choices and chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="", flush=True)
    if chunk.usage:
        print(f"\n\n비용(KRW): {chunk.usage.cost}")

실제로 응답한 모델·provider 는 어디서 보는가

models[] 로 후보를 여러 개 두면 요청한 id 가 반드시 응답한 id 는 아닙니다. 모든 청크에 실제로 응답한 쪽이 실립니다.

"model": "google/gemma-4-26b-a4b",
"provider": "MyIP Local GPU"

같은 값이 X-MyIP-Model / X-MyIP-Provider 응답 헤더에도 있으며, 이긴 후보가 응답을 시작하는 순간 정해집니다. 모델 폴백 을 보세요.

마지막 usage 청크

[DONE] 바로 앞의 data: 이벤트에 usage 가 실리고, 우리가 여기에 비용을 주입합니다.

json
{"choices":[],"usage":{"prompt_tokens":18,"completion_tokens":9,"total_tokens":27,"cost":0.001890,"cost_details":{"upstream_inference_cost":0.000383}}}

usage.cost 는 잔액에서 실제로 차감된 금액 그 자체이며, 단위는 원(KRW), 소수점 6자리에서 반올림됩니다 — 나중에 GET /generation 이 돌려주는 이 요청의 total_cost 와 같은 숫자입니다. 업스트림에 usage 를 요청하는 것(stream_options: {"include_usage": true})은 우리가 항상 대신 하므로 직접 설정할 필요가 없고, 여러분이 보낸 값은 무시됩니다.

스트림 시작 전 오류 vs 도중 오류

시점형태우리가 재시도하는가
어떤 후보도 요청을 수락하기 전평범한 JSON 오류, 정상적인 HTTP 상태예 — 체인이 자동으로 다음 후보로 넘어갑니다
후보가 응답을 시작한 뒤error 를 담은 SSE data: 이벤트, 그 뒤 [DONE]. HTTP 상태는 200 그대로아니오 — 모델 폴백 을 보세요

스트림 도중 오류 이벤트의 정확한 형태, 그리고 스트림 안에서는 HTTP 200 이 성공을 뜻하지 않는 이유는 API 레퍼런스 에 있습니다.

취소

클라이언트 요청을 중단하면(연결을 닫거나 AbortController.abort()) 업스트림 호출도 멈추고 그 시점에서 과금이 끝납니다 — 전체 응답이 아니라 취소 시점까지 생성된 토큰만큼만 청구됩니다. 취소된 요청의 사용 기록은 status: "cancelled" 이고, GET /generationcancelled: true 를 돌려줍니다.

typescript
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000); // 5초 후 포기

try {
  const response = await fetch('https://openrouter.myip.co.kr/api/v1/chat/completions', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.MYIP_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ model: 'google/gemma-4-26b-a4b', messages: [/* ... */], stream: true }),
    signal: controller.signal,
  });
  // ...위와 같은 방식으로 response.body 를 읽는다
} finally {
  clearTimeout(timer);
}

취소 전에 생성된 토큰이 없으면 과금하지 않지만, 이미 스트리밍된 만큼은 과금합니다 — 연결을 끊는 것이 부분 결과를 공짜로 얻는 방법이 되지는 않습니다.

/completions 의 스트리밍

레거시 /completions 도 스트리밍을 지원합니다. 청크의 object"text_completion" 이고 텍스트는 delta.content 대신 choices[].text 에 들어갑니다. usage 청크, [DONE], 오류 이벤트는 동일합니다.

관련 문서

마지막 수정 2026. 9. 5.