From 361fdee5f4578c44a210d357663973d18c11820c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 6 Aug 2026 16:04:09 +0800 Subject: [PATCH 1/5] fix(runtime): send the Responses wire the options it actually reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider options were filed under the namespace the adapter kind implies, but a Responses wire is dialled through the native OpenAI provider whatever that kind is, and the SDK reads only `openai` there. Every option Maka sent an openai-compatible provider on that wire was dropped on the floor: deepseek-v4-flash asked for `reasoningEffort: max` and the request carried no reasoning parameter at all, so an 89-task benchmark billed as max effort ran on whatever the service defaults to. `store: false` is the other half. It is not a storage preference, it is the switch that makes the SDK request `include: ['reasoning.encrypted_content']`, which is the only way a reasoning chain survives a tool call on this wire. The include is further gated on the SDK believing the model reasons, which it decides by parsing the model id for an OpenAI naming scheme; deepseek-v4-flash fails that parse however it is served, so our declared thinking variants say so with `forceReasoning` instead of letting a name decide. Keyed on the resolved wire rather than per provider, which is what makes the Copilot Responses branch redundant and covers the 22 affected models across deepseek, opencode, and opencode-go. Measured against the live DeepSeek endpoint: the corrected request is accepted and the effort now reaches the service. DeepSeek still returns no encrypted content, and replaying its plaintext reasoning back — as `content[]` or as `summary[]` — does not reach the model either, so cross-step reasoning is not available there at all. That is a provider limit, not a caller bug, and it is why this wire never paid off on that model; asking correctly is what lets the providers that do support it pay off. The contract test sweeps every model resolving to the wire, and a body-level test pins the include, since options that look correct can still lose it. --- .../__tests__/model-factory-thinking.test.ts | 31 +++-- .../__tests__/responses-wire-contract.test.ts | 119 ++++++++++++++++++ packages/runtime/src/model-factory.ts | 40 ++++-- 3 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 packages/runtime/src/__tests__/responses-wire-contract.test.ts diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 3c0b9af4ad..69306437fe 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -230,13 +230,20 @@ describe('buildProviderOptions: thinking level', () => { [...thinkingVariantsForModel('deepseek', 'deepseek-v4-flash')], ['high', 'max'], ); + // deepseek-v4-flash serves the Responses wire, which the native OpenAI + // provider dials: its namespace is `openai`, and the provider's own + // namespace would be dropped on the floor. `store: false` and + // `forceReasoning` are what earn the encrypted reasoning the next step + // replays, so they hold even when no level was picked. assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'high'), { - deepseek: { reasoningEffort: 'high' }, + openai: { store: false, forceReasoning: true, reasoningEffort: 'high' }, }); assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'max'), { - deepseek: { reasoningEffort: 'max' }, + openai: { store: false, forceReasoning: true, reasoningEffort: 'max' }, + }); + assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'off'), { + openai: { store: false, forceReasoning: true }, }); - assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'off'), {}); assert.deepEqual([...thinkingVariantsForModel('zai-coding-plan', 'glm-5.1')], []); assert.deepEqual([...thinkingVariantsForModel('zai-coding-plan', 'glm-4.5-air')], []); // miss model (deepseek-chat non-reasoning) drops level @@ -246,8 +253,10 @@ describe('buildProviderOptions: thinking level', () => { test('family fallback wires per-model override adapters under their SDK namespaces', () => { // opencode serves models across several protocols via models.dev package // overrides; the family fallback must emit the namespace each SDK consumes. + // gpt-5.5 resolves to the Responses wire, so it takes the wire branch and + // its encrypted-reasoning terms rather than a bare effort. assert.deepEqual(buildProviderOptions(conn('opencode'), 'gpt-5.5', 'high'), { - openai: { reasoningEffort: 'high' }, + openai: { store: false, forceReasoning: true, reasoningEffort: 'high' }, }); assert.deepEqual(buildProviderOptions(conn('opencode'), 'claude-fable-5', 'high'), { anthropic: { effort: 'high' }, @@ -277,8 +286,10 @@ describe('buildProviderOptions: thinking level', () => { ...conn('github-copilot'), models: [{ id: 'gpt-5.5', apiProtocol: 'openai-responses' as const }], }; + // The Responses protocol takes the shared wire branch, so Copilot asks for + // encrypted reasoning on the same terms every other Responses model does. assert.deepEqual(buildProviderOptions(responses, 'gpt-5.5', 'high'), { - openai: { reasoningEffort: 'high' }, + openai: { store: false, forceReasoning: true, reasoningEffort: 'high' }, }); }); @@ -573,10 +584,16 @@ describe('buildProviderOptions: openai-compatible namespace', () => { { 'zai-coding-plan': { reasoningEffort: 'max' } }, ); }); - test('deepseek uses its own raw namespace', () => { + test('deepseek uses its own raw namespace on the chat wire, the OpenAI one on Responses', () => { + assert.deepEqual( + buildProviderOptions(conn('deepseek', 'deepseek'), 'deepseek-v4-pro', 'high'), + { + deepseek: { reasoningEffort: 'high' }, + }, + ); assert.deepEqual( buildProviderOptions(conn('deepseek', 'deepseek'), 'deepseek-v4-flash', 'high'), - { deepseek: { reasoningEffort: 'high' } }, + { openai: { store: false, forceReasoning: true, reasoningEffort: 'high' } }, ); }); }); diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts new file mode 100644 index 0000000000..d056eeb2c0 --- /dev/null +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { LlmConnection } from '@maka/core'; +import { + modelMetadataIdsForProvider, + PROVIDER_REGISTRY, + thinkingVariantsForModel, +} from '@maka/core'; +import { buildProviderOptions, getAIModel } from '@maka/runtime'; +import { resolveModelRuntime } from '../model-runtime.js'; + +function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmConnection { + return { + slug, + name: slug, + providerType, + defaultModel: 'm', + enabled: true, + createdAt: 0, + updatedAt: 0, + }; +} + +/** + * Every Responses wire is dialled through `createOpenAI(...).responses(...)` in + * `getAIModel`, whatever the adapter kind is — the native OpenAI provider is the + * only one that speaks it. Its provider-options namespace is `openai`, and the + * SDK reads no other one: `parseProviderOptions` only falls back to `openai` + * when the model's own namespace differs, which it never does here. Options + * filed under a compatible provider's own namespace are silently dropped. + */ +function openAiNamespace(options: Record): Record | undefined { + const inner = options.openai; + return typeof inner === 'object' && inner !== null + ? (inner as Record) + : undefined; +} + +describe('responses wire contract', () => { + test('every Responses model asks for encrypted reasoning it can replay', () => { + // `store: false` is not a privacy preference here, it is the switch that + // makes the SDK add `include: ['reasoning.encrypted_content']`. Without it + // the provider returns reasoning items carrying an id and nothing else, so + // every replayed step hands the model an empty shell and the reasoning + // chain never survives a tool call. + const gaps: string[] = []; + for (const providerType of Object.keys(PROVIDER_REGISTRY) as LlmConnection['providerType'][]) { + if (PROVIDER_REGISTRY[providerType].runtimeAdapter?.kind === 'unavailable') continue; + const modelIds = new Set([ + ...PROVIDER_REGISTRY[providerType].fallbackModels, + ...modelMetadataIdsForProvider(providerType), + ]); + for (const modelId of modelIds) { + let wire: string; + try { + wire = resolveModelRuntime({ providerType }, modelId).wire; + } catch { + continue; + } + if (wire !== 'openai-responses') continue; + // Sweep the declared levels and the unset case: `store` is a property + // of the wire, not of a thinking choice, so a model reaches this branch + // whether or not a level was picked. + for (const level of [undefined, ...thinkingVariantsForModel(providerType, modelId)]) { + const options = buildProviderOptions(conn(providerType), modelId, level); + const openai = openAiNamespace(options); + const label = `${providerType}/${modelId} @ ${level ?? 'unset'}`; + if (!openai) { + gaps.push(`${label} wires no openai namespace: ${JSON.stringify(options)}`); + } else if (openai.store !== false) { + gaps.push(`${label} omits store:false: ${JSON.stringify(options)}`); + } + } + } + } + assert.deepEqual(gaps, []); + }); +}); + +describe('responses wire request body', () => { + test('a non-OpenAI-named Responses model still asks for encrypted reasoning', async () => { + // The options shape alone does not prove the wire: the SDK only adds the + // include when it also believes the model reasons, and it decides that by + // parsing the model id. `deepseek-v4-flash` fails that parse, so this + // asserts the body the provider actually receives rather than the options + // we hand the SDK. Without `forceReasoning` the include silently vanishes + // while the options still look right. + let body: Record | undefined; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ + id: 'r', + object: 'response', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }) as unknown as typeof globalThis.fetch; + + const connection = conn('deepseek'); + const model = getAIModel({ + connection, + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + providerOptions: buildProviderOptions(connection, 'deepseek-v4-flash', 'max'), + }); + + assert.equal(body?.store, false); + assert.deepEqual(body?.include, ['reasoning.encrypted_content']); + assert.equal((body?.reasoning as { effort?: string } | undefined)?.effort, 'max'); + }); +}); diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 774c62a6bb..6f4efc504c 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -482,9 +482,37 @@ function buildFamilyWire( level: ThinkingLevel | undefined, thinkingOptions: ThinkingOptions | undefined, ): SharedV4ProviderOptions { - if (!level) return {}; - const { adapter } = resolveModelRuntime(connection, modelId); - const reasoningEffort = level === 'off' ? 'none' : level; + const { adapter, wire } = resolveModelRuntime(connection, modelId); + const reasoningEffort = level ? (level === 'off' ? 'none' : level) : undefined; + // Whatever the adapter kind, a Responses wire is dialled through the native + // OpenAI provider (`getAIModel`), so `openai` is the only provider-options + // namespace the SDK will read: an openai-compatible provider's own namespace + // is silently dropped there — including the effort, so a model asking for + // `max` sent no reasoning parameter at all. `store: false` is not a storage + // preference, it is the switch that makes the SDK request + // `include: ['reasoning.encrypted_content']`, which is the only way a + // reasoning chain survives a tool call on this wire. Whether a given provider + // honours that request is its own business — DeepSeek accepts it and returns + // nothing — but not asking guarantees the answer. That is a property of the + // wire, not of a thinking choice, so it holds whether or not a level was + // picked. + // + // The include is gated on the SDK also believing this is a reasoning model, + // and it decides that by parsing the model id for an OpenAI naming scheme — + // `deepseek-v4-flash` and `grok-4.5` fail that test however they are served. + // Our own declared thinking variants are the authority on that question, so + // say so with `forceReasoning` rather than letting a name decide. + if (wire === 'openai-responses') { + const reasons = thinkingVariantsForModel(connection.providerType, modelId).length > 0; + return { + openai: { + store: false, + ...(reasons ? { forceReasoning: true } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + }, + }; + } + if (!reasoningEffort) return {}; switch (adapter.kind) { case 'openai-compatible': return { @@ -509,14 +537,12 @@ function buildFamilyWire( }; case 'github-copilot': { // Copilot routes per account-declared model protocol (mirrors the - // getAIModel case), defaulting to its OpenAI-compatible chat wire. + // getAIModel case), defaulting to its OpenAI-compatible chat wire. Its + // Responses protocol is answered by the wire branch above. const copilotProtocol = connection.models?.find((model) => model.id === modelId)?.apiProtocol; if (copilotProtocol === 'anthropic-messages') { return level !== 'off' ? { anthropic: { effort: level } } : {}; } - if (copilotProtocol === 'openai-responses') { - return { openai: { reasoningEffort } }; - } return { 'github-copilot': { reasoningEffort } }; } default: From 58de64fc3da11e48bca3c9063d90943e20eef21b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 6 Aug 2026 17:48:59 +0800 Subject: [PATCH 2/5] docs(runtime): correct what store:false buys on a Responses wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed encrypted content is the only way a reasoning chain survives a tool call there, and the contract test carried that claim in its name. Measured against the live DeepSeek API, it is false: DeepSeek speaks the Open Responses shape, returning reasoning as plaintext in `content[].reasoning_text` and consuming it replayed in the same shape. Omitting it under a call id the server cannot associate to its own record is a 400, and input token accounting scales with the replayed length, so it is loaded into context rather than tolerated. What `store: false` actually buys is narrower and still worth asking for: the SDK adds `include: ['reasoning.encrypted_content']`, and drops reasoning items that came back without one — a replayable chain from providers that speak that contract, and no empty husks from those that do not. Say that, and leave the dialect question to a transport. --- .../__tests__/responses-wire-contract.test.ts | 12 +++++----- packages/runtime/src/model-factory.ts | 22 ++++++++++++------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index d056eeb2c0..02237df618 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -37,12 +37,14 @@ function openAiNamespace(options: Record): Record { - test('every Responses model asks for encrypted reasoning it can replay', () => { + test('every Responses model asks for encrypted reasoning', () => { // `store: false` is not a privacy preference here, it is the switch that - // makes the SDK add `include: ['reasoning.encrypted_content']`. Without it - // the provider returns reasoning items carrying an id and nothing else, so - // every replayed step hands the model an empty shell and the reasoning - // chain never survives a tool call. + // makes the SDK add `include: ['reasoning.encrypted_content']` and drop + // reasoning items that came back without one. Asking is the only way a + // provider that speaks that contract can hand back a replayable chain; + // for one that does not, the drop stops us shipping empty husks. This + // asserts we ask — not that any given provider answers, and not that + // encrypted content is the only dialect a provider may carry reasoning in. const gaps: string[] = []; for (const providerType of Object.keys(PROVIDER_REGISTRY) as LlmConnection['providerType'][]) { if (PROVIDER_REGISTRY[providerType].runtimeAdapter?.kind === 'unavailable') continue; diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 6f4efc504c..4ab8246771 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -488,14 +488,20 @@ function buildFamilyWire( // OpenAI provider (`getAIModel`), so `openai` is the only provider-options // namespace the SDK will read: an openai-compatible provider's own namespace // is silently dropped there — including the effort, so a model asking for - // `max` sent no reasoning parameter at all. `store: false` is not a storage - // preference, it is the switch that makes the SDK request - // `include: ['reasoning.encrypted_content']`, which is the only way a - // reasoning chain survives a tool call on this wire. Whether a given provider - // honours that request is its own business — DeepSeek accepts it and returns - // nothing — but not asking guarantees the answer. That is a property of the - // wire, not of a thinking choice, so it holds whether or not a level was - // picked. + // `max` sent no reasoning parameter at all. + // + // `store: false` is not a storage preference, it is the switch that makes the + // SDK ask for `include: ['reasoning.encrypted_content']` and, on the request + // side, drop any reasoning item that came back without one. Both halves are + // what we want here: a provider that speaks the encrypted-content contract + // gets a replayable chain, and one that does not stops shipping empty husks + // it could never replay. Which of the two a given provider is remains its own + // business, and this says nothing about how it carries reasoning otherwise — + // DeepSeek returns plaintext in `content[].reasoning_text` and consumes it in + // the same shape, a dialect the SDK neither reads nor writes. Bridging that + // is a transport's job, not this function's. Either way `store` is a property + // of the wire rather than of a thinking choice, so it holds whether or not a + // level was picked. // // The include is gated on the SDK also believing this is a reasoning model, // and it decides that by parsing the model id for an OpenAI naming scheme — From 5f81f06d64abe7786ee82a9ac724ef79fbe4bfbc Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 6 Aug 2026 17:54:59 +0800 Subject: [PATCH 3/5] fix(runtime): read the reasoning DeepSeek actually streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responses providers disagree about where reasoning text lives. The Open Responses shape streams it as `response.reasoning_text.delta`; OpenAI's extension streams `response.reasoning_summary_text.delta`, and the SDK reads that one alone. The item events around it are identical either way, so against DeepSeek a reasoning part opened, closed, and carried nothing — measured live: same call, 1 start, 1 end, 0 characters without this and 92 with it. That is where the empty reasoning in stored sessions comes from, and why a session trace shows a model that thought for thousands of tokens saying nothing about it. Translate the response side only, so this cannot alter a request the provider already accepts, and only ever fill a gap, so a provider that populates its own summary keeps it. Streaming carries `content_index` across to `summary_index`, which is what keeps concurrent reasoning items apart in the SDK's part keying; frames are reassembled across chunk boundaries rather than assumed whole. Mount it per provider rather than per wire. A Responses wire says nothing about which reasoning shape is spoken — the same model reached through a gateway may well speak the other one — and DeepSeek is the only provider here measured against the live API. --- ...enai-responses-plaintext-reasoning.test.ts | 216 ++++++++++++++++++ packages/runtime/src/model-factory.ts | 16 +- ...responses-plaintext-reasoning-transport.ts | 132 +++++++++++ 3 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts create mode 100644 packages/runtime/src/openai-responses-plaintext-reasoning-transport.ts diff --git a/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts b/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts new file mode 100644 index 0000000000..b5e4e94464 --- /dev/null +++ b/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { LlmConnection } from '@maka/core'; +import { getAIModel } from '@maka/runtime'; + +function conn(providerType: LlmConnection['providerType']): LlmConnection { + return { + slug: 'test', + name: 'test', + providerType, + defaultModel: 'm', + enabled: true, + createdAt: 0, + updatedAt: 0, + }; +} + +const ITEM_ID = 'd2fb9f45-39e8-4f9e-9cc3-999d591a27ab'; +const REASONING = 'The user asks if 91 is prime. 91 = 7 x 13, so it is composite.'; + +/** + * Recorded from a live `deepseek-v4-flash` streaming call: a reasoning item is + * opened and closed by the same `output_item` events the SDK already reads, + * while the text itself arrives on `response.reasoning_text.delta`. That is why + * the reasoning part used to survive the round trip carrying nothing. + */ +function deepseekReasoningStream(deltas: string[]): string { + const events: Array> = [ + { type: 'response.created', response: { id: 'r' } }, + { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'reasoning', id: ITEM_ID, status: 'in_progress', content: [], summary: [] }, + }, + ...deltas.map((delta, index) => ({ + type: 'response.reasoning_text.delta', + content_index: 0, + delta, + item_id: ITEM_ID, + output_index: 0, + sequence_number: 4 + index, + })), + { + type: 'response.reasoning_text.done', + content_index: 0, + item_id: ITEM_ID, + output_index: 0, + text: deltas.join(''), + }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: ITEM_ID, + status: 'completed', + content: [{ type: 'reasoning_text', text: deltas.join('') }], + summary: [], + }, + }, + { + type: 'response.completed', + response: { + id: 'r', + object: 'response', + created_at: 0, + model: 'deepseek-v4-flash', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + ]; + return `${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`; +} + +function sseFetch(body: string, chunkSize = Number.MAX_SAFE_INTEGER): typeof globalThis.fetch { + return (async () => { + const encoder = new TextEncoder(); + return new Response( + new ReadableStream({ + start(controller) { + for (let at = 0; at < body.length; at += chunkSize) { + controller.enqueue(encoder.encode(body.slice(at, at + chunkSize))); + } + controller.close(); + }, + }), + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ); + }) as unknown as typeof globalThis.fetch; +} + +async function streamReasoning( + providerType: LlmConnection['providerType'], + fetch: typeof globalThis.fetch, +): Promise { + const model = getAIModel({ + connection: conn(providerType), + apiKey: 'test-key', + modelId: providerType === 'deepseek' ? 'deepseek-v4-flash' : 'grok-4.5', + fetch, + }); + const { stream } = await model.doStream({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + providerOptions: { openai: { store: false, forceReasoning: true } }, + }); + let text = ''; + for await (const part of stream) { + if (part.type === 'reasoning-delta') text += part.delta; + } + return text; +} + +describe('open responses plaintext reasoning', () => { + test('streamed reasoning text reaches the model stream', async () => { + const deltas = ['The user asks if 91 is prime. ', '91 = 7 x 13, ', 'so it is composite.']; + const text = await streamReasoning('deepseek', sseFetch(deepseekReasoningStream(deltas))); + assert.equal(text, deltas.join('')); + }); + + test('reasoning survives frames split across chunk boundaries', async () => { + // SSE frames arrive on arbitrary byte boundaries, so a translator that + // assumes one whole event per chunk loses text without failing loudly. + const deltas = ['The user asks if 91 is prime. ', '91 = 7 x 13, ', 'so it is composite.']; + const text = await streamReasoning('deepseek', sseFetch(deepseekReasoningStream(deltas), 7)); + assert.equal(text, deltas.join('')); + }); + + test('a provider we have not measured is left untranslated', async () => { + // The transport is mounted per provider, not per wire. xAI reaches the same + // Responses wire but its reasoning shape has not been measured, so nothing + // should rewrite its stream on the strength of the wire alone. + const text = await streamReasoning('xai', sseFetch(deepseekReasoningStream(['ignored']))); + assert.equal(text, ''); + }); + + test('non-streaming reasoning content is read', async () => { + let body: string | undefined; + const fetch = (async () => { + body = JSON.stringify({ + id: 'r', + object: 'response', + created_at: 0, + model: 'deepseek-v4-flash', + status: 'completed', + output: [ + { + type: 'reasoning', + id: ITEM_ID, + summary: [], + content: [{ type: 'reasoning_text', text: REASONING }], + }, + ], + usage: { input_tokens: 1, output_tokens: 1 }, + }); + return new Response(body, { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as unknown as typeof globalThis.fetch; + const model = getAIModel({ + connection: conn('deepseek'), + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + const result = await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + providerOptions: { openai: { store: false, forceReasoning: true } }, + }); + const reasoning = result.content.filter((part) => part.type === 'reasoning'); + assert.equal(reasoning.length, 1); + assert.equal(reasoning[0].text, REASONING); + }); + + test('a summary the provider populated itself is left alone', async () => { + // Filling a gap is safe; overwriting is not. A provider that speaks both + // shapes keeps whatever it chose to put in the summary. + const fetch = (async () => + new Response( + JSON.stringify({ + id: 'r', + object: 'response', + created_at: 0, + model: 'deepseek-v4-flash', + status: 'completed', + output: [ + { + type: 'reasoning', + id: ITEM_ID, + summary: [{ type: 'summary_text', text: 'provider summary' }], + content: [{ type: 'reasoning_text', text: REASONING }], + }, + ], + usage: { input_tokens: 1, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + )) as unknown as typeof globalThis.fetch; + const model = getAIModel({ + connection: conn('deepseek'), + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + const result = await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + providerOptions: { openai: { store: false, forceReasoning: true } }, + }); + const reasoning = result.content.filter((part) => part.type === 'reasoning'); + assert.deepEqual( + reasoning.map((part) => part.text), + ['provider summary'], + ); + }); +}); diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 4ab8246771..ca403081b5 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -24,6 +24,7 @@ import { createOpenAiChatReasoningTransportState, type OpenAiChatReasoningTransportState, } from './openai-chat-reasoning-transport.js'; +import { createOpenAiResponsesPlaintextReasoningTransport } from './openai-responses-plaintext-reasoning-transport.js'; import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js'; import { anthropicV1BaseUrl, googleV1BetaBaseUrl } from './provider-urls.js'; import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime.js'; @@ -130,7 +131,20 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { ); } if (wire === 'openai-responses') { - return createOpenAI({ apiKey, baseURL, fetch }).responses(modelId); + // Measured against the live API rather than inferred from the wire: + // DeepSeek streams reasoning as `response.reasoning_text.delta`, which + // the SDK never reads, so its reasoning parts arrive empty. Keep this + // to the provider we have evidence for — a Responses wire says nothing + // about which reasoning shape a provider speaks, and the others + // reaching here have not been measured. + const speaksPlaintextReasoning = connection.providerType === 'deepseek'; + return createOpenAI({ + apiKey, + baseURL, + fetch: speaksPlaintextReasoning + ? createOpenAiResponsesPlaintextReasoningTransport(fetch ?? globalThis.fetch) + : fetch, + }).responses(modelId); } if (reasoningReplay.kind !== 'openai-chat-plaintext') { throw new Error('OpenAI-compatible Chat wire requires plaintext reasoning replay'); diff --git a/packages/runtime/src/openai-responses-plaintext-reasoning-transport.ts b/packages/runtime/src/openai-responses-plaintext-reasoning-transport.ts new file mode 100644 index 0000000000..afe310efca --- /dev/null +++ b/packages/runtime/src/openai-responses-plaintext-reasoning-transport.ts @@ -0,0 +1,132 @@ +/** + * Responses providers disagree about where reasoning text lives. + * + * The Open Responses shape carries it as `content[].reasoning_text`, streamed + * as `response.reasoning_text.delta`. OpenAI's own extension carries it as + * `summary[].summary_text`, streamed as `response.reasoning_summary_text.delta`, + * and `@ai-sdk/openai` — the only provider that speaks this wire for us — reads + * that one alone. Against a provider using the standard shape it produces a + * reasoning part that opens and closes around nothing: `output_item.added` and + * `output_item.done` already name a reasoning item, so start and end arrive, + * while every delta lands on a field nobody reads. That is where the empty + * reasoning in stored sessions comes from. + * + * This translates the response side only. Nothing here changes what we send, + * so it cannot alter a request the provider already accepts. It also only ever + * fills a gap: a summary the provider populated itself is left alone, so a + * provider speaking both shapes keeps its own. + */ + +const PLAINTEXT_DELTA = 'response.reasoning_text.delta'; +const SUMMARY_DELTA = 'response.reasoning_summary_text.delta'; + +export function createOpenAiResponsesPlaintextReasoningTransport( + fetchImpl: typeof globalThis.fetch = globalThis.fetch, +): typeof globalThis.fetch { + return async (input, init) => translateResponse(await fetchImpl(input, init)); +} + +function translateResponse(response: Response): Response { + if (!response.ok || !response.body) return response; + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('text/event-stream')) { + return new Response(response.body.pipeThrough(translateEventStream()), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } + if (!contentType.includes('application/json')) return response; + return new Response( + new ReadableStream({ + async start(controller) { + const body = await response.text(); + controller.enqueue(new TextEncoder().encode(translateJsonBody(body))); + controller.close(); + }, + }), + { status: response.status, statusText: response.statusText, headers: response.headers }, + ); +} + +/** + * SSE frames are newline-delimited but arrive on arbitrary chunk boundaries, so + * the tail of a chunk is held back until its line terminator shows up. Every + * line that is not a reasoning-text delta is passed through byte for byte. + */ +function translateEventStream(): TransformStream { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let pending = ''; + return new TransformStream({ + transform(chunk, controller) { + pending += decoder.decode(chunk, { stream: true }); + const lines = pending.split('\n'); + pending = lines.pop() ?? ''; + for (const line of lines) { + controller.enqueue(encoder.encode(`${translateEventLine(line)}\n`)); + } + }, + flush(controller) { + if (pending) controller.enqueue(encoder.encode(translateEventLine(pending))); + }, + }); +} + +function translateEventLine(line: string): string { + if (!line.startsWith('data:')) return line; + const payload = line.slice('data:'.length).trim(); + if (!payload || payload === '[DONE]') return line; + let event: unknown; + try { + event = JSON.parse(payload); + } catch { + return line; + } + if (!isRecord(event) || event.type !== PLAINTEXT_DELTA) return line; + // The SDK keys an in-flight reasoning part by `${item_id}:${summary_index}`, + // and the part it opened on `output_item.added` is index 0. The standard + // shape indexes the same position as `content_index`, so carrying it across + // keeps multiple reasoning items — and multiple parts within one — apart. + const { content_index: contentIndex, ...rest } = event; + return `data: ${JSON.stringify({ + ...rest, + type: SUMMARY_DELTA, + summary_index: typeof contentIndex === 'number' ? contentIndex : 0, + })}`; +} + +/** + * The non-streaming path reads `summary[]` only, so a reasoning item arrives + * with an empty string in it. Only the top-level output items are touched: + * a `reasoning_text` appearing anywhere else — inside tool arguments, say — is + * not a reasoning item and is left alone. + */ +function translateJsonBody(body: string): string { + let payload: unknown; + try { + payload = JSON.parse(body); + } catch { + return body; + } + if (!isRecord(payload) || !Array.isArray(payload.output)) return body; + let changed = false; + const output = payload.output.map((item) => { + if (!isRecord(item) || item.type !== 'reasoning') return item; + if (Array.isArray(item.summary) && item.summary.length > 0) return item; + if (!Array.isArray(item.content)) return item; + const summary = item.content.flatMap((part) => + isRecord(part) && part.type === 'reasoning_text' && typeof part.text === 'string' + ? [{ type: 'summary_text', text: part.text }] + : [], + ); + if (summary.length === 0) return item; + changed = true; + return { ...item, summary }; + }); + return changed ? JSON.stringify({ ...payload, output }) : body; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} From ae92f31365ba3ac6cdc74a55c52d39b4d954d8f4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 6 Aug 2026 18:32:39 +0800 Subject: [PATCH 4/5] fix(runtime): stop a rewritten response describing the body it replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-encoding a body invalidates the headers that framed the old one: `content-length` becomes a count of bytes nobody will send, and `content-encoding` names a compression `fetch` already undid. The chat transport learned this and deletes both; the plaintext reasoning transport copied the headers across instead, so both its SSE and JSON paths handed on a length that no longer matched. Give that rule one owner and have both transports read it from there, rather than leaving a second copy to drift out of a third. Release the bytes the SSE decoder is still holding when the stream ends. A character split across a chunk boundary completes when the next chunk lands, so this only shows up on a body that was cut mid-sequence — but then the tail belongs to the caller either way, as a replacement character rather than nothing at all. Say what the two channels actually do about a summary the provider filled in. The JSON path sees the whole body and leaves such a summary alone. A stream is read a line at a time and a plaintext delta says nothing about a summary delta that has not arrived, so that path translates unconditionally; buffering a whole reasoning item to learn otherwise would cost the streaming the wire exists for. --- packages/runtime/src/http-response.ts | 17 +++++++++++ .../src/openai-chat-reasoning-transport.ts | 13 ++------- ...responses-plaintext-reasoning-transport.ts | 28 ++++++++++++------- 3 files changed, 37 insertions(+), 21 deletions(-) create mode 100644 packages/runtime/src/http-response.ts diff --git a/packages/runtime/src/http-response.ts b/packages/runtime/src/http-response.ts new file mode 100644 index 0000000000..8aa3d388cc --- /dev/null +++ b/packages/runtime/src/http-response.ts @@ -0,0 +1,17 @@ +/** + * A transport that rewrites a response body must not carry over the headers + * that described the old one. `content-length` becomes a lie the moment the + * body is re-encoded, and `content-encoding` names a compression that `fetch` + * has already undone. Both belong to the framing of the response we replaced, + * not to the one we are handing on. + */ +export function responseWithBody(response: Response, body: BodyInit): Response { + const headers = new Headers(response.headers); + headers.delete('content-encoding'); + headers.delete('content-length'); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} diff --git a/packages/runtime/src/openai-chat-reasoning-transport.ts b/packages/runtime/src/openai-chat-reasoning-transport.ts index da9e8352ef..f00071012d 100644 --- a/packages/runtime/src/openai-chat-reasoning-transport.ts +++ b/packages/runtime/src/openai-chat-reasoning-transport.ts @@ -1,3 +1,5 @@ +import { responseWithBody } from './http-response.js'; + export interface OpenAiChatReasoningTransport { fetch: typeof globalThis.fetch; transformRequestBody: (body: Record) => Record; @@ -227,17 +229,6 @@ function normalizeKimiUsage(usage: Record): Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/packages/runtime/src/openai-responses-plaintext-reasoning-transport.ts b/packages/runtime/src/openai-responses-plaintext-reasoning-transport.ts index afe310efca..0818705e24 100644 --- a/packages/runtime/src/openai-responses-plaintext-reasoning-transport.ts +++ b/packages/runtime/src/openai-responses-plaintext-reasoning-transport.ts @@ -12,10 +12,19 @@ * reasoning in stored sessions comes from. * * This translates the response side only. Nothing here changes what we send, - * so it cannot alter a request the provider already accepts. It also only ever - * fills a gap: a summary the provider populated itself is left alone, so a - * provider speaking both shapes keeps its own. + * so it cannot alter a request the provider already accepts. + * + * The two channels differ in how much they can defer to the provider. A whole + * JSON body shows both fields at once, so that path fills a gap and nothing + * more: a summary the provider populated itself is left alone. A stream is read + * one line at a time and a plaintext delta carries no evidence about what some + * later summary delta will say, so that path translates unconditionally. A + * provider that streamed both shapes at the same index would end up with the + * two concatenated. None does today — DeepSeek, the only one measured, streams + * plaintext alone — and buffering a whole reasoning item to find out would cost + * the streaming that is the point of the wire. */ +import { responseWithBody } from './http-response.js'; const PLAINTEXT_DELTA = 'response.reasoning_text.delta'; const SUMMARY_DELTA = 'response.reasoning_summary_text.delta'; @@ -30,14 +39,11 @@ function translateResponse(response: Response): Response { if (!response.ok || !response.body) return response; const contentType = response.headers.get('content-type') ?? ''; if (contentType.includes('text/event-stream')) { - return new Response(response.body.pipeThrough(translateEventStream()), { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); + return responseWithBody(response, response.body.pipeThrough(translateEventStream())); } if (!contentType.includes('application/json')) return response; - return new Response( + return responseWithBody( + response, new ReadableStream({ async start(controller) { const body = await response.text(); @@ -45,7 +51,6 @@ function translateResponse(response: Response): Response { controller.close(); }, }), - { status: response.status, statusText: response.statusText, headers: response.headers }, ); } @@ -68,6 +73,9 @@ function translateEventStream(): TransformStream { } }, flush(controller) { + // Bytes the decoder is still holding belong to a character split across + // the last chunk boundary; without this final decode they are dropped. + pending += decoder.decode(); if (pending) controller.enqueue(encoder.encode(translateEventLine(pending))); }, }); From eedbbad37cc3270ef4b5de469645a84d3e10dcd7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 6 Aug 2026 18:32:50 +0800 Subject: [PATCH 5/5] test(runtime): pin what the reasoning transport must not change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mutation-testing the transport found the suite blind to its worst failure: dropping every `output_text.delta` line — the assistant's whole reply — left all five tests green, because the fixture carried reasoning events and nothing else. The transport rewrites every response body from this provider, so the parts it has no business touching are the ones most worth asserting. The fixture now carries a message item and the tests read the reply back. The chunk-splitting fixture cut the string, not the bytes, so every chunk held whole characters and the 7-byte case could never split a multi-byte sequence: removing the decoder's `stream` flag stayed green. Cut the encoded bytes and reason in the language DeepSeek answers a Chinese prompt in, and the flag is pinned by the case it exists for. Two invariants have no end-to-end seam and are read at the transport instead. The SDK opens a second reasoning part only on an event no measured provider sends, so a fixture producing one would describe nobody — but `content_index` naming the same position as `summary_index` is the transport's own contract and testable alone. Likewise the SDK's event parser discards an unterminated final line whatever it holds, so only here can a truncated body show whether its last bytes survived. Correct what the wire contract says about namespaces: the retry under `openai` belongs to the Responses model and fires only for Azure. `parseProviderOptions` reads the one namespace it is handed, so options under a compatible provider's own name are never looked at rather than missed by a fallback. --- ...enai-responses-plaintext-reasoning.test.ts | 166 ++++++++++++++++-- .../__tests__/responses-wire-contract.test.ts | 9 +- 2 files changed, 157 insertions(+), 18 deletions(-) diff --git a/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts b/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts index b5e4e94464..bec5e90fa7 100644 --- a/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts +++ b/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import type { LlmConnection } from '@maka/core'; import { getAIModel } from '@maka/runtime'; +import { createOpenAiResponsesPlaintextReasoningTransport } from '../openai-responses-plaintext-reasoning-transport.js'; function conn(providerType: LlmConnection['providerType']): LlmConnection { return { @@ -16,15 +17,22 @@ function conn(providerType: LlmConnection['providerType']): LlmConnection { } const ITEM_ID = 'd2fb9f45-39e8-4f9e-9cc3-999d591a27ab'; +const MESSAGE_ID = 'msg_4a1f0c7b'; const REASONING = 'The user asks if 91 is prime. 91 = 7 x 13, so it is composite.'; +const ANSWER = 'No — 91 is 7 x 13.'; /** * Recorded from a live `deepseek-v4-flash` streaming call: a reasoning item is * opened and closed by the same `output_item` events the SDK already reads, * while the text itself arrives on `response.reasoning_text.delta`. That is why * the reasoning part used to survive the round trip carrying nothing. + * + * The assistant's own reply is part of the fixture because the transport + * rewrites every DeepSeek response body, not just the reasoning in it: a + * translator that dropped the message entirely would be the worst failure this + * code can have, and only an assertion on the reply can see it. */ -function deepseekReasoningStream(deltas: string[]): string { +function deepseekReasoningStream(deltas: string[], answer = ANSWER): string { const events: Array> = [ { type: 'response.created', response: { id: 'r' } }, { @@ -58,6 +66,35 @@ function deepseekReasoningStream(deltas: string[]): string { summary: [], }, }, + { + type: 'response.output_item.added', + output_index: 1, + item: { + type: 'message', + id: MESSAGE_ID, + status: 'in_progress', + role: 'assistant', + content: [], + }, + }, + { + type: 'response.output_text.delta', + content_index: 0, + delta: answer, + item_id: MESSAGE_ID, + output_index: 1, + }, + { + type: 'response.output_item.done', + output_index: 1, + item: { + type: 'message', + id: MESSAGE_ID, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: answer, annotations: [] }], + }, + }, { type: 'response.completed', response: { @@ -74,14 +111,19 @@ function deepseekReasoningStream(deltas: string[]): string { return `${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`; } +/** + * Chunks are cut from the encoded bytes, not from the string: slicing the + * string would hand every chunk a whole character and quietly make multi-byte + * text untestable, which is the failure this harness exists to expose. + */ function sseFetch(body: string, chunkSize = Number.MAX_SAFE_INTEGER): typeof globalThis.fetch { return (async () => { - const encoder = new TextEncoder(); + const bytes = new TextEncoder().encode(body); return new Response( new ReadableStream({ start(controller) { - for (let at = 0; at < body.length; at += chunkSize) { - controller.enqueue(encoder.encode(body.slice(at, at + chunkSize))); + for (let at = 0; at < bytes.length; at += chunkSize) { + controller.enqueue(bytes.slice(at, at + chunkSize)); } controller.close(); }, @@ -91,10 +133,25 @@ function sseFetch(body: string, chunkSize = Number.MAX_SAFE_INTEGER): typeof glo }) as unknown as typeof globalThis.fetch; } -async function streamReasoning( +/** A stream cut short by `missingBytes`, as a dropped connection would leave it. */ +function truncatingFetch(body: string, missingBytes: number): typeof globalThis.fetch { + const bytes = new TextEncoder().encode(body); + return (async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(bytes.slice(0, bytes.length - missingBytes)); + controller.close(); + }, + }), + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + )) as unknown as typeof globalThis.fetch; +} + +async function streamParts( providerType: LlmConnection['providerType'], fetch: typeof globalThis.fetch, -): Promise { +): Promise<{ reasoning: string; text: string }> { const model = getAIModel({ connection: conn(providerType), apiKey: 'test-key', @@ -105,34 +162,53 @@ async function streamReasoning( prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], providerOptions: { openai: { store: false, forceReasoning: true } }, }); + let reasoning = ''; let text = ''; for await (const part of stream) { - if (part.type === 'reasoning-delta') text += part.delta; + if (part.type === 'reasoning-delta') reasoning += part.delta; + if (part.type === 'text-delta') text += part.delta; } - return text; + return { reasoning, text }; } describe('open responses plaintext reasoning', () => { test('streamed reasoning text reaches the model stream', async () => { const deltas = ['The user asks if 91 is prime. ', '91 = 7 x 13, ', 'so it is composite.']; - const text = await streamReasoning('deepseek', sseFetch(deepseekReasoningStream(deltas))); - assert.equal(text, deltas.join('')); + const parts = await streamParts('deepseek', sseFetch(deepseekReasoningStream(deltas))); + assert.equal(parts.reasoning, deltas.join('')); + assert.equal(parts.text, ANSWER); + }); + + test('everything the transport does not translate passes through untouched', async () => { + // The transport rewrites every response body from this provider, so the + // reply it is not there to change is the one thing most worth pinning: + // dropping message frames wholesale would otherwise leave the suite green. + const parts = await streamParts( + 'deepseek', + sseFetch(deepseekReasoningStream(['thinking'], 'The answer is 42.')), + ); + assert.equal(parts.text, 'The answer is 42.'); }); test('reasoning survives frames split across chunk boundaries', async () => { // SSE frames arrive on arbitrary byte boundaries, so a translator that // assumes one whole event per chunk loses text without failing loudly. - const deltas = ['The user asks if 91 is prime. ', '91 = 7 x 13, ', 'so it is composite.']; - const text = await streamReasoning('deepseek', sseFetch(deepseekReasoningStream(deltas), 7)); - assert.equal(text, deltas.join('')); + // The text is deliberately not ASCII: DeepSeek reasons in the language it + // was asked in, and a 7-byte chunk cuts these characters mid-sequence, so + // this also pins the decoder's cross-chunk state. + const deltas = ['用户问 91 是不是质数。', '91 = 7 × 13,', '所以它是合数。']; + const parts = await streamParts('deepseek', sseFetch(deepseekReasoningStream(deltas), 7)); + assert.equal(parts.reasoning, deltas.join('')); + assert.equal(parts.text, ANSWER); }); test('a provider we have not measured is left untranslated', async () => { // The transport is mounted per provider, not per wire. xAI reaches the same // Responses wire but its reasoning shape has not been measured, so nothing // should rewrite its stream on the strength of the wire alone. - const text = await streamReasoning('xai', sseFetch(deepseekReasoningStream(['ignored']))); - assert.equal(text, ''); + const parts = await streamParts('xai', sseFetch(deepseekReasoningStream(['ignored']))); + assert.equal(parts.reasoning, ''); + assert.equal(parts.text, ANSWER); }); test('non-streaming reasoning content is read', async () => { @@ -174,6 +250,66 @@ describe('open responses plaintext reasoning', () => { assert.equal(reasoning[0].text, REASONING); }); + test('the position of a reasoning part is carried across, not flattened', async () => { + // Read at the transport rather than end to end: the SDK opens a second + // reasoning part only on `reasoning_summary_part.added`, which no measured + // provider sends, so a fixture producing one would describe nobody. What + // the transport owns is narrower and testable on its own — `content_index` + // names the same position `summary_index` does, and collapsing it to 0 + // would merge parts the provider kept apart. + const source = [ + `data: ${JSON.stringify({ type: 'response.reasoning_text.delta', content_index: 2, delta: 'x', item_id: ITEM_ID })}`, + 'data: [DONE]', + '', + ].join('\n\n'); + const translated = createOpenAiResponsesPlaintextReasoningTransport(sseFetch(source))( + 'https://example.invalid', + ); + const body = await (await translated).text(); + const event = JSON.parse( + body + .split('\n') + .find((line) => line.includes('summary_index')) + ?.slice('data: '.length) ?? '', + ); + assert.equal(event.type, 'response.reasoning_summary_text.delta'); + assert.equal(event.summary_index, 2); + assert.equal('content_index' in event, false); + }); + + test('a truncated body does not swallow the bytes it cut through', async () => { + // A character split across a chunk boundary completes when the next chunk + // lands, so only a body that ends mid-sequence leaves bytes inside the + // decoder. Those bytes belong to the caller either way: released, they + // surface as a replacement character; held, they vanish with no trace that + // the stream was cut. Read at the transport because the SDK's event parser + // discards an unterminated final line whatever it holds. + const truncated = truncatingFetch(`data: 合数`, 1); + const translated = + await createOpenAiResponsesPlaintextReasoningTransport(truncated)('https://example.invalid'); + assert.equal(await translated.text(), 'data: 合�'); + }); + + test('rewritten bodies do not keep the old body framing headers', async () => { + // The body is re-encoded, so a copied `content-length` describes something + // that no longer exists. + const source = `data: ${JSON.stringify({ type: 'response.reasoning_text.delta', content_index: 0, delta: 'x', item_id: ITEM_ID })}\n\n`; + const framed = (async () => + new Response(source, { + status: 200, + headers: { + 'content-type': 'text/event-stream', + 'content-length': String(source.length), + 'content-encoding': 'gzip', + }, + })) as unknown as typeof globalThis.fetch; + const translated = + await createOpenAiResponsesPlaintextReasoningTransport(framed)('https://example.invalid'); + assert.equal(translated.headers.get('content-length'), null); + assert.equal(translated.headers.get('content-encoding'), null); + assert.equal(translated.headers.get('content-type'), 'text/event-stream'); + }); + test('a summary the provider populated itself is left alone', async () => { // Filling a gap is safe; overwriting is not. A provider that speaks both // shapes keeps whatever it chose to put in the summary. diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index 02237df618..6d6d5e089c 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -25,9 +25,12 @@ function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmCo * Every Responses wire is dialled through `createOpenAI(...).responses(...)` in * `getAIModel`, whatever the adapter kind is — the native OpenAI provider is the * only one that speaks it. Its provider-options namespace is `openai`, and the - * SDK reads no other one: `parseProviderOptions` only falls back to `openai` - * when the model's own namespace differs, which it never does here. Options - * filed under a compatible provider's own namespace are silently dropped. + * SDK reads no other one: the Responses model picks its namespace by asking + * whether its own provider name contains `azure`, and only that Azure case ever + * retries under `openai`. `parseProviderOptions` itself reads the one namespace + * it is handed and nothing else, so options filed under a compatible provider's + * own namespace are not dropped by a fallback that missed — they are never + * looked at. */ function openAiNamespace(options: Record): Record | undefined { const inner = options.openai;