diff --git a/src/client/core.ts b/src/client/core.ts index 57e2383..b0c304c 100644 --- a/src/client/core.ts +++ b/src/client/core.ts @@ -13,6 +13,7 @@ import type { import { convertToAnthropicRequest, parseAnthropicStream } from './anthropic'; import { createHttpError, formatRequestError, GLMRequestError, normalizeRequestError } from './error'; import { analyzeContextOverflow } from './error/overflow-retry'; +import { convertToResponsesRequest, parseResponsesStream } from './responses'; import { trackRateLimitHeaders, waitIfRateLimited } from './rate-limit'; // Retry configuration for transient HTTP errors (429, 502, 503, 504). @@ -67,8 +68,8 @@ function isOpencodeGateway(baseUrl: string): boolean { * Lightweight SSE-streaming GLM API client. * No external dependencies — uses Node's built-in fetch. * - * Supports both OpenAI-compatible (`/chat/completions`) and - * Anthropic-compatible (`/v1/messages`) protocols. + * Supports OpenAI-compatible (`/chat/completions`), Anthropic-compatible + * (`/v1/messages`), and OpenAI Responses (`/responses`) protocols. */ export class GLMClient { constructor( @@ -95,10 +96,15 @@ export class GLMClient { callbacks: StreamCallbacks, cancellationToken?: CancellationToken, ): Promise { - const dispatch = (req: GLMRequest) => - this.protocol === 'anthropic' - ? this.streamAnthropicCompletion(req, callbacks, cancellationToken) - : this.streamOpenAIChatCompletion(req, callbacks, cancellationToken); + const dispatch = (req: GLMRequest) => { + if (this.protocol === 'anthropic') { + return this.streamAnthropicCompletion(req, callbacks, cancellationToken); + } + if (this.protocol === 'responses') { + return this.streamResponsesCompletion(req, callbacks, cancellationToken); + } + return this.streamOpenAIChatCompletion(req, callbacks, cancellationToken); + }; // Phase 1: retry with backoff for transient rate-limit / availability errors. let lastError: unknown; @@ -393,6 +399,68 @@ export class GLMClient { } } + /** + * Stream using OpenAI Responses `/responses` endpoint. + */ + private async streamResponsesCompletion( + request: GLMRequest, + callbacks: StreamCallbacks, + cancellationToken?: CancellationToken, + ): Promise { + const controller = new AbortController(); + const cancelListener = cancellationToken?.onCancellationRequested(() => { + controller.abort(); + }); + if (cancellationToken?.isCancellationRequested) { + cancelListener?.dispose(); + controller.abort(); + return; + } + + try { + const responsesRequest = convertToResponsesRequest(request); + + await waitIfRateLimited(this.baseUrl); + const response = await fetch(`${this.baseUrl}/responses`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + ...(isOpencodeGateway(this.baseUrl) ? getOpenCodeGatewayHeaders() : {}), + }, + body: safeStringify(responsesRequest), + signal: controller.signal, + }); + + if (!response.ok) { + throw await createHttpError(response, { + baseUrl: this.baseUrl, + request, + }); + } + + trackRateLimitHeaders(this.baseUrl, response.headers); + + if (!response.body) { + throw new Error('No response body received'); + } + + const reader = response.body.getReader(); + try { + await parseResponsesStream(reader, callbacks); + } finally { + await reader.cancel().catch((err) => { + if (!isAbortError(err)) { + logger.warn('Error cancelling Responses stream reader:', err); + } + }); + } + } finally { + cancelListener?.dispose(); + controller.abort(); + } + } + /** * Stream using Anthropic-compatible `/v1/messages` endpoint. */ diff --git a/src/client/responses/convert.ts b/src/client/responses/convert.ts new file mode 100644 index 0000000..908a2b6 --- /dev/null +++ b/src/client/responses/convert.ts @@ -0,0 +1,175 @@ +import type { GLMMessage, GLMRequest, GLMTool } from '../../types'; + +interface ResponsesTextPart { + type: 'input_text' | 'output_text'; + text: string; +} + +interface ResponsesMessageItem { + type: 'message'; + role: 'user' | 'assistant'; + content: ResponsesTextPart[]; +} + +interface ResponsesFunctionCallItem { + type: 'function_call'; + call_id: string; + name: string; + arguments: string; +} + +interface ResponsesFunctionCallOutputItem { + type: 'function_call_output'; + call_id: string; + output: string; +} + +type ResponsesInputItem = + | ResponsesMessageItem + | ResponsesFunctionCallItem + | ResponsesFunctionCallOutputItem; + +interface ResponsesFunctionTool { + type: 'function'; + name: string; + description?: string; + parameters: Record; +} + +export interface ResponsesRequest { + model: string; + input: ResponsesInputItem[]; + instructions?: string; + tools?: ResponsesFunctionTool[]; + tool_choice?: 'none' | 'auto' | 'required'; + stream: boolean; + max_output_tokens?: number; + temperature?: number; + top_p?: number; + reasoning?: { effort: 'low' | 'medium' | 'high' }; +} + +/** + * Convert an internal GLMRequest to the OpenAI Responses API format. + */ +export function convertToResponsesRequest(request: GLMRequest): ResponsesRequest { + const instructions = extractInstructions(request.messages); + const input = convertInput(request.messages); + const tools = request.tools?.length ? request.tools.map(convertTool) : undefined; + + const responsesRequest: ResponsesRequest = { + model: request.model, + input, + stream: request.stream, + }; + + if (instructions) { + responsesRequest.instructions = instructions; + } + if (tools) { + responsesRequest.tools = tools; + } + if (request.tool_choice) { + responsesRequest.tool_choice = request.tool_choice; + } + if (request.max_tokens !== undefined) { + responsesRequest.max_output_tokens = request.max_tokens; + } + if (request.temperature !== undefined) { + responsesRequest.temperature = request.temperature; + } + if (request.top_p !== undefined) { + responsesRequest.top_p = request.top_p; + } + if (request.thinking?.type === 'enabled') { + responsesRequest.reasoning = { effort: mapReasoningEffort(request.reasoning_effort) }; + } + + return responsesRequest; +} + +function extractInstructions(messages: GLMMessage[]): string | undefined { + const parts: string[] = []; + for (const msg of messages) { + if (msg.role === 'system') { + parts.push(msg.content); + } + } + if (parts.length === 0) { + return undefined; + } + return parts.join('\n\n'); +} + +function convertInput(messages: GLMMessage[]): ResponsesInputItem[] { + const input: ResponsesInputItem[] = []; + + for (const msg of messages) { + if (msg.role === 'system') { + continue; + } + + if (msg.role === 'tool') { + if (!msg.tool_call_id) { + throw new Error('Tool message is missing tool_call_id'); + } + input.push({ + type: 'function_call_output', + call_id: msg.tool_call_id, + output: msg.content, + }); + continue; + } + + if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) { + if (msg.content) { + input.push(createAssistantMessage(msg.content)); + } + for (const tc of msg.tool_calls) { + input.push({ + type: 'function_call', + call_id: tc.id, + name: tc.function.name, + arguments: tc.function.arguments, + }); + } + continue; + } + + if (msg.role === 'user') { + input.push({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: msg.content }], + }); + continue; + } + + if (msg.role === 'assistant') { + input.push(createAssistantMessage(msg.content)); + } + } + + return input; +} + +function createAssistantMessage(content: string): ResponsesMessageItem { + return { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: content }], + }; +} + +function convertTool(tool: GLMTool): ResponsesFunctionTool { + return { + type: 'function', + name: tool.function.name, + description: tool.function.description, + parameters: tool.function.parameters ?? { type: 'object', properties: {} }, + }; +} + +function mapReasoningEffort(effort: GLMRequest['reasoning_effort']): 'low' | 'medium' | 'high' { + return effort === 'max' ? 'high' : 'medium'; +} diff --git a/src/client/responses/index.ts b/src/client/responses/index.ts new file mode 100644 index 0000000..0d0bac1 --- /dev/null +++ b/src/client/responses/index.ts @@ -0,0 +1,3 @@ +export { convertToResponsesRequest } from './convert'; +export type { ResponsesRequest } from './convert'; +export { parseResponsesStream } from './stream'; diff --git a/src/client/responses/stream.ts b/src/client/responses/stream.ts new file mode 100644 index 0000000..77d99b7 --- /dev/null +++ b/src/client/responses/stream.ts @@ -0,0 +1,220 @@ +import { logger } from '../../logger'; +import type { GLMToolCall, GLMUsage, StreamCallbacks } from '../../types'; + +interface ResponsesSSEPayload { + type?: string; + delta?: string; + text?: string; + name?: string; + arguments?: string; + call_id?: string; + item?: { + id?: string; + type?: string; + call_id?: string; + name?: string; + }; + response?: { + usage?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + input_tokens_details?: { cached_tokens?: number }; + }; + }; + error?: { message?: string; type?: string }; +} + +interface PendingFunctionCall { + callId: string; + name: string; + arguments: string; +} + +/** + * Parse an OpenAI Responses API SSE stream and dispatch to StreamCallbacks. + */ +export async function parseResponsesStream( + reader: ReadableStreamDefaultReader, + callbacks: StreamCallbacks, +): Promise { + const decoder = new TextDecoder(); + let buffer = ''; + let latestUsage: GLMUsage | undefined; + const pendingCalls = new Map(); + const emittedCallIds = new Set(); + + const processPayload = (payload: ResponsesSSEPayload): boolean => { + const type = payload.type; + if (!type) { + return false; + } + + switch (type) { + case 'response.output_text.delta': + if (payload.delta) { + callbacks.onContent(payload.delta); + } + return false; + case 'response.reasoning_summary_text.delta': + case 'response.reasoning_text.delta': + if (payload.delta) { + callbacks.onThinking(payload.delta); + } + return false; + case 'response.output_item.added': { + const item = payload.item; + if (item?.type === 'function_call' && item.id) { + pendingCalls.set(item.id, { + callId: item.call_id ?? item.id, + name: item.name ?? '', + arguments: '', + }); + } + return false; + } + case 'response.function_call_arguments.delta': { + const itemId = getItemId(payload); + const pending = itemId ? pendingCalls.get(itemId) : undefined; + if (pending && payload.delta) { + pending.arguments += payload.delta; + } + return false; + } + case 'response.function_call_arguments.done': { + const itemId = getItemId(payload); + const pending = itemId ? pendingCalls.get(itemId) : undefined; + const callId = payload.call_id ?? pending?.callId; + const name = payload.name ?? pending?.name ?? ''; + const args = payload.arguments ?? pending?.arguments ?? ''; + if (callId && name && !emittedCallIds.has(callId)) { + emittedCallIds.add(callId); + callbacks.onToolCall({ + id: callId, + type: 'function', + function: { name, arguments: args }, + }); + } + if (itemId) { + pendingCalls.delete(itemId); + } + return false; + } + case 'response.completed': { + latestUsage = mapUsage(payload.response?.usage); + reportFinalUsage(callbacks, latestUsage); + callbacks.onDone(); + return true; + } + case 'error': { + const message = payload.error?.message ?? 'Responses API stream error'; + throw new Error(message); + } + default: + return false; + } + }; + + const processEventBlock = (rawEvent: string): boolean => { + const lines = rawEvent.replace(/\r\n/g, '\n').split('\n'); + let dataLine: string | undefined; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith(':')) { + continue; + } + if (trimmed.startsWith('data:')) { + dataLine = trimmed.slice(5).trimStart(); + } + } + if (!dataLine || dataLine === '[DONE]') { + if (dataLine === '[DONE]') { + reportFinalUsage(callbacks, latestUsage); + callbacks.onDone(); + return true; + } + return false; + } + + try { + const payload = JSON.parse(dataLine) as ResponsesSSEPayload; + return processPayload(payload); + } catch (error) { + logger.error('Failed to parse Responses SSE payload:', dataLine.slice(0, 200), error); + return false; + } + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + const normalized = buffer.replace(/\r\n/g, '\n'); + const events = normalized.split('\n\n'); + buffer = events.pop() || ''; + + for (const rawEvent of events) { + if (processEventBlock(rawEvent)) { + return; + } + } + } + + buffer += decoder.decode(); + if (buffer.length > 0) { + for (const rawEvent of buffer.split('\n\n')) { + if (processEventBlock(rawEvent)) { + return; + } + } + } + + for (const pending of pendingCalls.values()) { + if (pending.callId && pending.name && !emittedCallIds.has(pending.callId)) { + emittedCallIds.add(pending.callId); + callbacks.onToolCall({ + id: pending.callId, + type: 'function', + function: { name: pending.name, arguments: pending.arguments }, + }); + } + } + reportFinalUsage(callbacks, latestUsage); + callbacks.onDone(); +} + +function getItemId(payload: ResponsesSSEPayload): string | undefined { + const itemId = (payload as { item_id?: string }).item_id; + return typeof itemId === 'string' ? itemId : payload.item?.id; +} + +function mapUsage(usage: unknown): GLMUsage | undefined { + if (!usage || typeof usage !== 'object') { + return undefined; + } + const record = usage as { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + input_tokens_details?: { cached_tokens?: number }; + }; + const inputTokens = record.input_tokens ?? 0; + const outputTokens = record.output_tokens ?? 0; + const cachedTokens = record.input_tokens_details?.cached_tokens ?? 0; + return { + prompt_tokens: inputTokens, + completion_tokens: outputTokens, + total_tokens: record.total_tokens ?? inputTokens + outputTokens, + prompt_cache_hit_tokens: cachedTokens, + }; +} + +function reportFinalUsage(callbacks: StreamCallbacks, usage: GLMUsage | undefined): void { + if (!usage || !callbacks.onUsage) { + return; + } + callbacks.onUsage(usage); +} diff --git a/src/config.ts b/src/config.ts index 37b47be..f4a5791 100644 --- a/src/config.ts +++ b/src/config.ts @@ -408,7 +408,7 @@ function normalizeApiProtocol( value: unknown, fallback: ApiProtocol | undefined, ): ApiProtocol | undefined { - return value === 'openai' || value === 'anthropic' ? value : fallback; + return value === 'openai' || value === 'anthropic' || value === 'responses' ? value : fallback; } function normalizeCustomModel(entry: unknown): ModelDefinition | undefined { diff --git a/src/endpoint.ts b/src/endpoint.ts index 34c9f26..29934e7 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -11,10 +11,12 @@ export const GLM_INTERNATIONAL_API_HOST = 'api.z.ai'; // // OpenCode Go is a low-cost subscription that serves a curated set of open // coding models behind a single API key. The OpenAI-compatible endpoint is -// reached at `…/v1/chat/completions` and the Anthropic-compatible endpoint at -// `…/v1/messages`. Because the client appends `/chat/completions` (OpenAI) or -// `/v1/messages` (Anthropic) to the base URL, the two presets use different -// base URLs so the final request URLs line up exactly with the docs. +// reached at `…/v1/chat/completions`, the Anthropic-compatible endpoint at +// `…/v1/messages`, and a subset of models on the Responses API at +// `…/v1/responses`. Because the client appends `/chat/completions` (OpenAI), +// `/v1/messages` (Anthropic), or `/responses` (Responses) to the base URL, +// the presets use different base URLs so the final request URLs line up exactly +// with the docs. export const OPENCODE_GO_API_HOST = 'opencode.ai'; export const OPENCODE_GO_OPENAI_BASE_URL = `https://${OPENCODE_GO_API_HOST}/zen/go/v1`; export const OPENCODE_GO_ANTHROPIC_BASE_URL = `https://${OPENCODE_GO_API_HOST}/zen/go`; @@ -46,6 +48,8 @@ export function resolveEndpointBaseUrl(preset: EndpointPreset): string { return OPENCODE_GO_OPENAI_BASE_URL; case 'opencode-go-anthropic': return OPENCODE_GO_ANTHROPIC_BASE_URL; + case 'opencode-go-responses': + return OPENCODE_GO_OPENAI_BASE_URL; case 'opencode-zen': return OPENCODE_ZEN_OPENAI_BASE_URL; case 'opencode-zen-anthropic': @@ -57,7 +61,9 @@ export function resolveEndpointBaseUrl(preset: EndpointPreset): string { * Resolve the "request an API key" landing page for a single preset value. */ export function resolveEndpointApiKeyUrl(preset: EndpointPreset): string { - return preset === 'opencode-go' || preset === 'opencode-go-anthropic' + return preset === 'opencode-go' + || preset === 'opencode-go-anthropic' + || preset === 'opencode-go-responses' ? OPENCODE_GO_API_KEY_URL : OPENCODE_ZEN_API_KEY_URL; } @@ -66,9 +72,13 @@ export function resolveEndpointApiKeyUrl(preset: EndpointPreset): string { * The wire protocol implied by a preset value. */ export function resolveEndpointProtocol(preset: EndpointPreset): ApiProtocol { - return preset === 'opencode-go-anthropic' || preset === 'opencode-zen-anthropic' - ? 'anthropic' - : 'openai'; + if (preset === 'opencode-go-anthropic' || preset === 'opencode-zen-anthropic') { + return 'anthropic'; + } + if (preset === 'opencode-go-responses') { + return 'responses'; + } + return 'openai'; } /** The GLM/Z.ai platforms still recognized for manual `baseUrl` overrides. */ diff --git a/src/provider/opencode-models.ts b/src/provider/opencode-models.ts index 2462fbd..b3f404e 100644 --- a/src/provider/opencode-models.ts +++ b/src/provider/opencode-models.ts @@ -49,7 +49,8 @@ let cacheTimestamp = 0; // 1. Family rules (`resolveEndpointPresetForId`) → wire-protocol routing. // Validated against https://opencode.ai/docs/go and /docs/zen: Claude // and Qwen always speak Anthropic; MiniMax speaks Anthropic on the Go -// plan only; every other family speaks OpenAI-compatible. +// plan only; Luna/Grok/Muse Spark speak Responses; every other family +// speaks OpenAI-compatible. // 2. models.dev enrichment (`models-dev.ts`) → context windows, pricing, // capabilities — fetched live together with the catalogs. // 3. FALLBACK_MODELS → four hand-tuned baselines covering every plan × @@ -57,12 +58,27 @@ let cacheTimestamp = 0; // extension works before the first fetch or if both `/models` // endpoints ever change shape. +// OpenCode Go models served only on the Responses API (`/v1/responses`). +// https://opencode.ai/docs/go#endpoints +const RESPONSES_MODEL_IDS = new Set([ + 'gpt-5.6-luna', + 'grok-4.6', + 'muse-spark-1.2-contributor', +]); + +function speaksResponses(id: string): boolean { + return RESPONSES_MODEL_IDS.has(id); +} + /** * Resolve which endpoint preset (plan + wire protocol) a catalog model routes * to. `origin` is the catalog the id was discovered in. */ export function resolveEndpointPresetForId(id: string, origin: 'go' | 'zen'): EndpointPreset { const planPrefix = origin === 'go' ? 'opencode-go' : 'opencode-zen'; + if (origin === 'go' && speaksResponses(id)) { + return 'opencode-go-responses'; + } const speaksAnthropic = id.startsWith('claude') || id.startsWith('qwen') || diff --git a/src/provider/vision/service.ts b/src/provider/vision/service.ts index a15860a..499c3c5 100644 --- a/src/provider/vision/service.ts +++ b/src/provider/vision/service.ts @@ -86,7 +86,7 @@ export function createVisionService( } const config = createAutomaticOpenCodeVisionConfig(); - const apiKey = await authManager.getApiKeyForEndpoint(config.url); + const apiKey = await authManager.getApiKey(); const primary = createEndpointVisionDescriber(config, apiKey); logAutomaticGLMVisionModelSelected(primary.id, config.url); return new AutomaticVisionDescriber(primary, () => vscodeLm.get()); diff --git a/src/types.ts b/src/types.ts index 8079d61..102b3f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,7 +95,7 @@ export interface StreamCallbacks { // ---- Configuration types ---- -export type ApiProtocol = 'openai' | 'anthropic'; +export type ApiProtocol = 'openai' | 'anthropic' | 'responses'; /** * OpenCode endpoint preset — each value resolves to one base URL + wire protocol. @@ -106,6 +106,7 @@ export type ApiProtocol = 'openai' | 'anthropic'; export type EndpointPreset = | 'opencode-go' | 'opencode-go-anthropic' + | 'opencode-go-responses' | 'opencode-zen' | 'opencode-zen-anthropic'; diff --git a/test/client/responses.test.ts b/test/client/responses.test.ts new file mode 100644 index 0000000..30f93e6 --- /dev/null +++ b/test/client/responses.test.ts @@ -0,0 +1,240 @@ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; +import { GLMClient } from '../../src/client/core'; +import { convertToResponsesRequest } from '../../src/client/responses/convert'; +import { parseResponsesStream } from '../../src/client/responses/stream'; +import type { GLMRequest, StreamCallbacks } from '../../src/client/types'; +import { resolveEndpointPresetForId } from '../../src/provider/opencode-models'; +import { resolveEndpointProtocol } from '../../src/endpoint'; + +const servers: Server[] = []; + +afterEach(() => { + for (const server of servers) { + server.close(); + } + servers.length = 0; +}); + +function makeCallbacks() { + const state = { + content: '', + thinking: '', + toolCalls: [] as Array<{ name: string; arguments: string }>, + done: 0, + usage: undefined as { prompt_tokens: number; completion_tokens: number } | undefined, + error: undefined as Error | undefined, + }; + const callbacks: StreamCallbacks = { + onContent: (text) => { + state.content += text; + }, + onThinking: (text) => { + state.thinking += text; + }, + onToolCall: (toolCall) => { + state.toolCalls.push({ + name: toolCall.function.name, + arguments: toolCall.function.arguments, + }); + }, + onError: (error) => { + state.error = error; + }, + onDone: () => { + state.done += 1; + }, + onUsage: (usage) => { + state.usage = { + prompt_tokens: usage.prompt_tokens, + completion_tokens: usage.completion_tokens, + }; + }, + }; + return { state, callbacks }; +} + +describe('responses routing', () => { + it('pins Go Responses models to the responses preset', () => { + expect(resolveEndpointPresetForId('gpt-5.6-luna', 'go')).toBe('opencode-go-responses'); + expect(resolveEndpointPresetForId('grok-4.6', 'go')).toBe('opencode-go-responses'); + expect(resolveEndpointPresetForId('glm-5.2', 'go')).toBe('opencode-go'); + expect(resolveEndpointProtocol('opencode-go-responses')).toBe('responses'); + }); +}); + +describe('convertToResponsesRequest', () => { + it('maps system, user, assistant, and tool history into Responses input', () => { + const request: GLMRequest = { + model: 'gpt-5.6-luna', + stream: true, + max_tokens: 4096, + tool_choice: 'auto', + tools: [ + { + type: 'function', + function: { + name: 'read_file', + description: 'Read a file', + parameters: { type: 'object', properties: { path: { type: 'string' } } }, + }, + }, + ], + messages: [ + { role: 'system', content: 'Be concise.' }, + { role: 'user', content: 'Inspect src/main.ts' }, + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'read_file', arguments: '{"path":"src/main.ts"}' }, + }, + ], + }, + { role: 'tool', content: 'export {}', tool_call_id: 'call_1' }, + ], + thinking: { type: 'enabled' }, + reasoning_effort: 'max', + }; + + const converted = convertToResponsesRequest(request); + + expect(converted.instructions).toBe('Be concise.'); + expect(converted.max_output_tokens).toBe(4096); + expect(converted.reasoning).toEqual({ effort: 'high' }); + expect(converted.input).toEqual([ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Inspect src/main.ts' }], + }, + { + type: 'function_call', + call_id: 'call_1', + name: 'read_file', + arguments: '{"path":"src/main.ts"}', + }, + { + type: 'function_call_output', + call_id: 'call_1', + output: 'export {}', + }, + ]); + }); +}); + +describe('parseResponsesStream', () => { + it('streams assistant text and usage from Responses SSE events', async () => { + const sse = [ + 'data: {"type":"response.output_text.delta","delta":"Hello"}', + '', + 'data: {"type":"response.output_text.delta","delta":" Luna"}', + '', + 'data: {"type":"response.completed","response":{"usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15,"input_tokens_details":{"cached_tokens":4}}}}', + '', + ].join('\n'); + + const { state, callbacks } = makeCallbacks(); + const reader = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(sse)); + controller.close(); + }, + }).getReader(); + + await parseResponsesStream(reader, callbacks); + + expect(state.content).toBe('Hello Luna'); + expect(state.done).toBe(1); + expect(state.usage).toEqual({ prompt_tokens: 12, completion_tokens: 3 }); + }); + + it('streams reasoning and function-call arguments', async () => { + const sse = [ + 'data: {"type":"response.reasoning_summary_text.delta","delta":"think"}', + '', + 'data: {"type":"response.output_item.added","item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"grep"}}', + '', + 'data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\\"pattern\\":\\"foo\\"}"}', + '', + 'data: {"type":"response.function_call_arguments.done","item_id":"fc_1","call_id":"call_1","name":"grep","arguments":"{\\"pattern\\":\\"foo\\"}"}', + '', + 'data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}', + '', + ].join('\n'); + + const { state, callbacks } = makeCallbacks(); + const reader = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(sse)); + controller.close(); + }, + }).getReader(); + + await parseResponsesStream(reader, callbacks); + + expect(state.thinking).toBe('think'); + expect(state.toolCalls).toEqual([{ name: 'grep', arguments: '{"pattern":"foo"}' }]); + }); +}); + +describe('GLMClient responses protocol (mock server)', () => { + it('posts to /responses and parses the stream', async () => { + let requestPath = ''; + let requestBody: unknown; + + const server = createServer((req, res) => { + requestPath = req.url ?? ''; + let raw = ''; + req.on('data', (chunk) => { + raw += chunk; + }); + req.on('end', () => { + requestBody = JSON.parse(raw || '{}'); + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.end( + [ + 'data: {"type":"response.output_text.delta","delta":"ok"}', + '', + 'data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}', + '', + ].join('\n'), + ); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + const { port } = server.address() as AddressInfo; + + const client = new GLMClient(`http://127.0.0.1:${port}/v1`, 'test-key', 'responses'); + const { state, callbacks } = makeCallbacks(); + await client.streamChatCompletion( + { + model: 'gpt-5.6-luna', + stream: true, + messages: [{ role: 'user', content: 'hi' }], + }, + callbacks, + ); + + expect(requestPath).toBe('/v1/responses'); + expect(requestBody).toMatchObject({ + model: 'gpt-5.6-luna', + stream: true, + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hi' }], + }, + ], + }); + expect(state.content).toBe('ok'); + expect(state.done).toBe(1); + expect(state.error).toBeUndefined(); + }); +}); diff --git a/test/endpoint.test.ts b/test/endpoint.test.ts index 3b85a99..a0d7192 100644 --- a/test/endpoint.test.ts +++ b/test/endpoint.test.ts @@ -95,6 +95,7 @@ describe('endpoint preset resolver', () => { it('resolves every preset to its official base URL', () => { expect(resolveEndpointBaseUrl('opencode-go')).toBe(OPENCODE_GO_OPENAI_BASE_URL); expect(resolveEndpointBaseUrl('opencode-go-anthropic')).toBe(OPENCODE_GO_ANTHROPIC_BASE_URL); + expect(resolveEndpointBaseUrl('opencode-go-responses')).toBe(OPENCODE_GO_OPENAI_BASE_URL); expect(resolveEndpointBaseUrl('opencode-zen')).toBe(OPENCODE_ZEN_OPENAI_BASE_URL); expect(resolveEndpointBaseUrl('opencode-zen-anthropic')).toBe(OPENCODE_ZEN_ANTHROPIC_BASE_URL); }); @@ -109,6 +110,7 @@ describe('endpoint preset resolver', () => { it('maps each preset to its implied wire protocol', () => { expect(resolveEndpointProtocol('opencode-go')).toBe('openai'); expect(resolveEndpointProtocol('opencode-go-anthropic')).toBe('anthropic'); + expect(resolveEndpointProtocol('opencode-go-responses')).toBe('responses'); expect(resolveEndpointProtocol('opencode-zen')).toBe('openai'); expect(resolveEndpointProtocol('opencode-zen-anthropic')).toBe('anthropic'); }); diff --git a/test/provider/models.test.ts b/test/provider/models.test.ts index 3601fd2..a96ac23 100644 --- a/test/provider/models.test.ts +++ b/test/provider/models.test.ts @@ -100,7 +100,7 @@ describe('dual catalog pipeline (real captured IDs)', () => { } // Catalog-only newcomers surface as selectable picker entries. const luna = models.find((m) => m.id === 'gpt-5.6-luna'); - expect(luna?.endpointPreset).toBe('opencode-go'); + expect(luna?.endpointPreset).toBe('opencode-go-responses'); expect(luna?.name).toContain('Luna'); // Zen-only newcomer: known IDs keep their overlay pin (Claude is // Anthropic-style), unknown IDs get a plain zen pin.