// ADR-007: protocol layer is a pure function (with an injectable fetcher for tests). // ADR-008: 429/503 are retried 5x; other errors propagate. // Returns an AsyncIterable so the shell can consume streaming or non-streaming responses uniformly. import type { ChatCompletionRequest, StreamChunk, Usage } from './types.js' import { ProtocolError } from './types.js' import { parseSSEStream } from './stream.js' import { withRetry } from './retry.js' export type Fetcher = (url: string, init: RequestInit) => Promise export type StreamOptions = { fetcher?: Fetcher // defaults to global fetch maxRetries?: number // retries for 429/503 after the first attempt (default 5) baseDelayMs?: number // first retry waits this much, doubling each time (default 100) onUsage: (u: Usage) => void // shell receives usage here signal?: AbortSignal } const defaultFetcher: Fetcher = (url, init) => fetch(url, init) const DEFAULT_MAX_RETRIES = 5 const DEFAULT_BASE_DELAY_MS = 100 // We always stream. The server may not produce [DONE] but will close the connection. // The shell's loop will not consume the stream beyond its interest; an "ended" stream // just stops yielding. export async function* streamChat( url: string, request: ChatCompletionRequest, options: StreamOptions, ): AsyncIterable { const fetcher = options.fetcher ?? defaultFetcher const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS const doFetch = async (): Promise => { // The protocol layer always asks for a streaming response. The // OpenAI-compatible API only returns SSE when the request body // carries `stream: true`; without it the response is a single // JSON object that parseSSEStream would see as zero chunks, and // the IM would terminate with an empty `'completed'` answer. // We inject `stream: true` and `stream_options.include_usage` // automatically so the IM's `iter` / `token` / `time` guards are // reachable against every OpenAI-shaped provider. A caller that // explicitly sets `stream: false` keeps the caller's choice. const outgoing: ChatCompletionRequest = { ...request } if (outgoing.stream === undefined) outgoing.stream = true if (outgoing.stream === true) { const existing = (outgoing.stream_options ?? {}) as Record outgoing.stream_options = { ...existing, include_usage: true } } const init: RequestInit = { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(outgoing), } if (options.signal !== undefined) init.signal = options.signal let response: Response try { response = await fetcher(url, init) } catch (e) { // Caller-initiated abort propagates unchanged (no retry, no wrap). if (options.signal?.aborted) throw e // Connection failed before any response (DNS/TCP/TLS refusal). // Nothing was consumed, so retrying is safe. const msg = e instanceof Error ? e.message : String(e) throw new ProtocolError(0, `connection failed: ${msg}`, true) } if (!response.ok) { let body: unknown = null try { body = await response.json() } catch { /* keep body null */ } throw new ProtocolError(response.status, body, response.status === 429 || response.status === 503) } return response } const response = await withRetry(doFetch, { maxRetries, baseDelayMs }) if (!response.body) { throw new ProtocolError(0, 'no response body', false) } // Forward the response body to the SSE parser, then re-emit StreamChunk events. try { for await (const ev of parseSSEStream(response.body)) { if (ev.data === '[DONE]') { yield { type: 'done' } return } let json: unknown try { json = JSON.parse(ev.data) } catch { // Skip non-JSON lines silently (e.g. heartbeat comments made it through). continue } // OpenAI streaming error frame: the stream reports a failure mid-way. // Partial content was already consumed, so retry is unsafe. if (typeof json === 'object' && json !== null) { const err = (json as Record).error if (err) throw new ProtocolError(0, err, false) } yield* extractChunks(json, options.onUsage) } } catch (e) { if (e instanceof ProtocolError) throw e // Mid-stream interruption (reader.read() rejected, etc.). // Partial content was already consumed, so retry is unsafe. const msg = e instanceof Error ? e.message : String(e) throw new ProtocolError(0, `stream interrupted: ${msg}`, false) } } // OpenAI streaming format: each `data:` line carries a `choices[0].delta` with // `content`, `tool_calls` (array of deltas), and at the end a top-level `usage`. function* extractChunks(json: unknown, onUsage: (u: Usage) => void): Iterable { if (typeof json !== 'object' || json === null) return const j = json as Record // Usage can appear on any chunk (typically the last one before [DONE]). if (j.usage) { const u = normalizeUsage(j.usage) if (u) { onUsage(u) yield { type: 'usage', usage: u } } } const choices = j.choices if (!Array.isArray(choices) || choices.length === 0) return const delta = choices[0]?.delta as Record | undefined if (!delta) return if (typeof delta.content === 'string' && delta.content.length > 0) { yield { type: 'content_delta', text: delta.content } } // Reasoning models (GLM / DeepSeek-R1 on ARK, o1-style) stream their // thinking as `delta.reasoning_content` — verified against ARK // glm-5.3-flash 2026-09-06 (90 reasoning deltas in one streamed reply). // shellCall's aggregator ignores this chunk type (thinking is not reply // content); the signal-gate delta-bridge forwards it to the UI. if (typeof delta.reasoning_content === 'string' && delta.reasoning_content.length > 0) { yield { type: 'reasoning_delta', text: delta.reasoning_content } } if (Array.isArray(delta.tool_calls)) { for (const tc of delta.tool_calls) { if (typeof tc !== 'object' || tc === null) continue const t = tc as Record const out: { type: 'tool_call_delta'; index: number; id?: string; name?: string; arguments_delta?: string } = { type: 'tool_call_delta', index: typeof t.index === 'number' ? t.index : 0, } if (typeof t.id === 'string') out.id = t.id if (typeof t.function === 'object' && t.function !== null) { const fn = t.function as Record if (typeof fn.name === 'string') out.name = fn.name if (typeof fn.arguments === 'string') out.arguments_delta = fn.arguments } yield out } } const finish = (choices[0] as Record | undefined)?.finish_reason if (typeof finish === 'string' && (finish === 'stop' || finish === 'tool_calls' || finish === 'length' || finish === 'content_filter')) { yield { type: 'finish', reason: finish } } } const normalizeUsage = (raw: unknown): Usage | null => { if (typeof raw !== 'object' || raw === null) return null const r = raw as Record if (typeof r.prompt_tokens !== 'number' || typeof r.completion_tokens !== 'number' || typeof r.total_tokens !== 'number') { return null } // v0.32:推理 tokens(completion_tokens_details.reasoning_tokens,ARK 实测 // deepseek-v4/glm-5 都带)——缺省省略,不为不支持的上游造 0 值。 const details = r.completion_tokens_details as Record | undefined const reasoningTokens = typeof details?.reasoning_tokens === 'number' ? details.reasoning_tokens : undefined return { promptTokens: r.prompt_tokens, completionTokens: r.completion_tokens, totalTokens: r.total_tokens, ...(reasoningTokens !== undefined ? { reasoningTokens } : {}), } }