diff --git a/packages/core/src/__tests__/model-metadata.test.ts b/packages/core/src/__tests__/model-metadata.test.ts index 9eb6090bf8..4e1c54b4e7 100644 --- a/packages/core/src/__tests__/model-metadata.test.ts +++ b/packages/core/src/__tests__/model-metadata.test.ts @@ -104,4 +104,12 @@ describe('openAiAdapterApiProtocol', () => { assert.equal(openAiAdapterApiProtocol('muse-spark-1.2-contributor', 'opencode'), 'openai-chat'); assert.equal(openAiAdapterApiProtocol('minimax-m3', 'opencode-go'), 'openai-chat'); }); + + it('routes only Qwen3.8 Max through Alibaba Token Plan Responses', () => { + for (const providerType of ['alibaba-token-plan-cn', 'alibaba-token-plan'] as const) { + assert.equal(openAiAdapterApiProtocol('qwen3.8-max', providerType), 'openai-responses'); + assert.equal(openAiAdapterApiProtocol('qwen3.7-max', providerType), 'openai-chat'); + } + assert.equal(openAiAdapterApiProtocol('qwen3.8-max', 'alibaba-cn'), 'openai-chat'); + }); }); diff --git a/packages/core/src/__tests__/provider-catalog-contract.test.ts b/packages/core/src/__tests__/provider-catalog-contract.test.ts index d5a57da1f0..ef03f43fa2 100644 --- a/packages/core/src/__tests__/provider-catalog-contract.test.ts +++ b/packages/core/src/__tests__/provider-catalog-contract.test.ts @@ -78,6 +78,19 @@ describe('provider catalog contract — structural invariants over CATALOG_PROVI ); } }); + + it('delegates Alibaba Token Plan execution through one explicit Runtime profile', () => { + const delegated = Object.entries(PROVIDER_REGISTRY).flatMap(([providerType, definition]) => { + const adapter = definition.runtimeAdapter; + return adapter.kind === 'openai-compatible' && adapter.runtimeProfile + ? [{ providerType, runtimeProfile: adapter.runtimeProfile }] + : []; + }); + assert.deepEqual(delegated, [ + { providerType: 'alibaba-token-plan-cn', runtimeProfile: 'alibaba-token-plan' }, + { providerType: 'alibaba-token-plan', runtimeProfile: 'alibaba-token-plan' }, + ]); + }); }); describe('retired provider contract', () => { diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 75bbe31742..199fb601f5 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -44,6 +44,7 @@ import { type ProviderCategory, type ProviderDefaults, type ProviderRuntimeAdapter, + type ProviderRuntimeProfileId, type ProviderResponsesContract, type ProviderType, } from './provider-registry.js'; @@ -64,6 +65,7 @@ export type { ProviderCategory, ProviderDefaults, ProviderRuntimeAdapter, + ProviderRuntimeProfileId, ProviderResponsesContract, ProviderType, }; diff --git a/packages/core/src/model-metadata.ts b/packages/core/src/model-metadata.ts index 5d65b8fc4a..79f9acbeb7 100644 --- a/packages/core/src/model-metadata.ts +++ b/packages/core/src/model-metadata.ts @@ -125,11 +125,10 @@ export function lookupModelProviderOverride( /** * The request wire a model served over the OpenAI adapter must use. * - * OpenAI's `gpt-5*` families and xAI's `grok-4.5` are served only over the - * Responses API; every other model on the native OpenAI adapter uses Chat - * Completions. This is the single declared source of that protocol split, - * expressed through the {@link ModelInfo.apiProtocol} seam. It is consumed by - * the runtime model factory and the conformance matrix. + * Provider/model routing facts live here even when the concrete Responses SDK + * and replay policy are delegated to a Runtime profile. This is the single + * declared source of the default protocol split, expressed through the + * {@link ModelInfo.apiProtocol} seam. */ export function openAiAdapterApiProtocol( modelId: string, @@ -138,6 +137,8 @@ export function openAiAdapterApiProtocol( const id = modelId.trim(); return (providerType === 'deepseek' && deepSeekModelSupportsResponses(id)) || (providerType === 'opencode-go' && id === 'muse-spark-1.2-contributor') || + ((providerType === 'alibaba-token-plan-cn' || providerType === 'alibaba-token-plan') && + id === 'qwen3.8-max') || /^gpt-5/i.test(id) || ((providerType === 'xai' || providerType === 'xai-oauth') && id === 'grok-4.5') ? 'openai-responses' diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 68243afa64..36c3c44d0a 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -31,6 +31,13 @@ export type ProviderCatalogGroup = 'recommended' | 'plans' | 'api' | 'aggregator export type ApplyPatchProtocol = 'openai-structured' | 'codex-v4a-freeform'; +/** + * Stable reference to provider execution policy implemented by `@maka/runtime`. + * Core owns only this protocol-level delegation; SDK selection, replay + * carriers, and request mutation remain Runtime implementation details. + */ +export type ProviderRuntimeProfileId = 'alibaba-token-plan'; + export type ProviderResponsesContract = | { readonly adapter: 'openai'; @@ -41,6 +48,29 @@ export type ProviderResponsesContract = readonly reasoningReplay: 'plaintext-content'; }; +type OpenAiCompatibleRuntimeAdapterBase = { + kind: 'openai-compatible'; + name: 'provider' | 'connection'; + includeUsage?: boolean; + requireBaseUrl?: boolean; + replayAssistantReasoningAs?: 'reasoning'; + replayAssistantReasoningDetails?: true; +}; + +type OpenAiCompatibleRuntimeAdapter = OpenAiCompatibleRuntimeAdapterBase & + ( + | { + /** Presence enables a complete Core-owned Responses contract. */ + responses?: ProviderResponsesContract; + runtimeProfile?: never; + } + | { + responses?: never; + /** Explicitly delegates concrete execution policy to `@maka/runtime`. */ + runtimeProfile: ProviderRuntimeProfileId; + } + ); + type ProviderRuntimeAdapterDefinition = | { kind: 'anthropic'; auth: 'api-key' | 'bearer'; normalizeBaseUrl: boolean } /** @@ -53,16 +83,7 @@ type ProviderRuntimeAdapterDefinition = | { kind: 'google'; normalizeBaseUrl?: boolean } | { kind: 'github-copilot' } | { kind: 'cohere' } - | { - kind: 'openai-compatible'; - name: 'provider' | 'connection'; - includeUsage?: boolean; - requireBaseUrl?: boolean; - /** Presence enables Responses and fixes the only supported SDK/replay pairing. */ - responses?: ProviderResponsesContract; - replayAssistantReasoningAs?: 'reasoning'; - replayAssistantReasoningDetails?: true; - }; + | OpenAiCompatibleRuntimeAdapter; export type ProviderRuntimeAdapter = ProviderRuntimeAdapterDefinition & { /** Provider wire contract for ApplyPatch. Model support is resolved separately. */ @@ -1664,7 +1685,11 @@ const providerRegistry = { fallbackModels: [...alibabaTokenPlanModelIds], status: 'ready', protocol: 'openai', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { + kind: 'openai-compatible', + name: 'provider', + runtimeProfile: 'alibaba-token-plan', + }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'plans', @@ -1684,7 +1709,11 @@ const providerRegistry = { fallbackModels: [...alibabaTokenPlanModelIds], status: 'ready', protocol: 'openai', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { + kind: 'openai-compatible', + name: 'provider', + runtimeProfile: 'alibaba-token-plan', + }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', catalogGroup: 'plans', diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index de1647618a..c057a4eddb 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -11379,6 +11379,670 @@ describe('AiSdkBackend thinking persistence', () => { ); }); + test('Alibaba Responses keeps multiple streamed reasoning items distinct through replay', async () => { + const chunks: LanguageModelV4StreamPart[] = [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-item-1' }, + { type: 'reasoning-delta', id: 'reasoning-item-1', delta: 'first summary' }, + { + type: 'reasoning-end', + id: 'reasoning-item-1', + providerMetadata: { + 'alibaba-token-plan-cn': { + itemId: 'reasoning-item-1', + reasoningSummary: [{ type: 'summary_text', text: 'first summary' }], + reasoningContent: null, + }, + }, + }, + { type: 'reasoning-start', id: 'reasoning-item-2' }, + { type: 'reasoning-delta', id: 'reasoning-item-2', delta: 'second summary' }, + { + type: 'reasoning-end', + id: 'reasoning-item-2', + providerMetadata: { + 'alibaba-token-plan-cn': { + itemId: 'reasoning-item-2', + reasoningSummary: [{ type: 'summary_text', text: 'second summary' }], + reasoningContent: null, + }, + }, + }, + { type: 'reasoning-start', id: 'reasoning-item-empty' }, + { + type: 'reasoning-end', + id: 'reasoning-item-empty', + providerMetadata: { + 'alibaba-token-plan-cn': { + itemId: 'reasoning-item-empty', + reasoningSummary: [], + reasoningContent: null, + }, + }, + }, + { type: 'text-start', id: 'message-item' }, + { type: 'text-delta', id: 'message-item', delta: 'answer' }, + { type: 'text-end', id: 'message-item' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 3, text: 1, reasoning: 2 }, + }, + }, + ]; + const firstModel = new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }), + }, + }); + const tokenPlanConnection = { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + } as const; + const firstBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: tokenPlanConnection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => firstModel, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + const firstEvents: SessionEvent[] = []; + for await (const event of firstBackend.send({ + turnId: 'turn-prev', + text: 'question', + context: [], + })) { + firstEvents.push(event); + } + const thinkingCompletes = firstEvents.filter( + (event): event is Extract => + event.type === 'thinking_complete', + ); + assert.deepEqual( + thinkingCompletes.map((event) => [ + event.text, + (event.providerOptions?.makaResponses as { itemId?: unknown } | undefined)?.itemId, + ]), + [ + ['first summary', 'reasoning-item-1'], + ['second summary', 'reasoning-item-2'], + ['', 'reasoning-item-empty'], + ], + ); + + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-prev', + turnId: 'turn-prev', + now: () => 7, + newId: idGenerator(), + } as unknown as InvocationContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = firstEvents.map((event) => + mapSessionEventToRuntimeEvent(event, ctx, memory), + ); + const secondModel = completionModel(); + const secondBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: tokenPlanConnection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => secondModel, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + secondBackend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + runtimeContext, + }), + ); + + const prompt = compactPrompt(secondModel) as ModelMessage[]; + const assistant = prompt.find( + (message) => message.role === 'assistant' && Array.isArray(message.content), + ); + assert.ok(assistant && Array.isArray(assistant.content)); + assert.deepEqual( + assistant.content + .filter((part) => part.type === 'reasoning') + .map((part) => [ + part.text, + (part.providerOptions?.['alibaba-token-plan-cn'] as { itemId?: unknown } | undefined) + ?.itemId, + ]), + [ + ['first summary', 'reasoning-item-1'], + ['second summary', 'reasoning-item-2'], + ['', 'reasoning-item-empty'], + ], + ); + }); + + test('Alibaba Responses fails when streamed reasoning differs from the final summary', async (t) => { + // The early stop tears down the SDK stream while its settlement promises + // are still in flight; when those rejections land is scheduler-owned (on + // Windows they were observed after the test boundary). Trap unhandled + // rejections for the lifetime of this turn and assert the mismatch path + // leaves none behind, on every event loop, not just the one that raced. + const leakedRejections: unknown[] = []; + const trapUnhandledRejection = (reason: unknown): void => { + leakedRejections.push(reason); + }; + process.on('unhandledRejection', trapUnhandledRejection); + t.after(() => { + process.off('unhandledRejection', trapUnhandledRejection); + }); + const appended: AssistantMessage[] = []; + const model = new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-item' }, + { type: 'reasoning-delta', id: 'reasoning-item', delta: 'streamed text' }, + { + type: 'reasoning-end', + id: 'reasoning-item', + providerMetadata: { + 'alibaba-token-plan-cn': { + itemId: 'reasoning-item', + reasoningSummary: [{ type: 'summary_text', text: 'different final summary' }], + reasoningContent: null, + }, + }, + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + if (message.type === 'assistant') appended.push(message); + }, + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'question', context: [] })) { + events.push(event); + } + + assert.equal( + events.some((event) => event.type === 'error'), + true, + ); + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + assert.equal(JSON.stringify(appended).includes('makaResponses'), false); + + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-1', + turnId: 'turn-1', + now: () => 7, + newId: idGenerator(), + } as unknown as InvocationContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); + const recoveryModel = completionModel(); + const recoveryBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => recoveryModel, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + recoveryBackend.send({ + turnId: 'turn-2', + text: 'recover', + context: [], + runtimeContext, + }), + ); + assert.ok(compactPrompt(recoveryModel)); + // Let SDK teardown settle across macrotask cycles so a leaked rejection + // is caught before the trap comes off. + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.deepEqual( + leakedRejections, + [], + 'reasoning-mismatch teardown must not leak unhandled rejections', + ); + }); + + test('Alibaba Responses keeps a finalized item valid when the next item id is unsafe', async () => { + const invalidItemId = 'invalid\nreasoning-item'; + const model = new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-item-a' }, + { type: 'reasoning-delta', id: 'reasoning-item-a', delta: 'valid summary' }, + { + type: 'reasoning-end', + id: 'reasoning-item-a', + providerMetadata: { + 'alibaba-token-plan-cn': { + itemId: 'reasoning-item-a', + reasoningSummary: [{ type: 'summary_text', text: 'valid summary' }], + reasoningContent: null, + }, + }, + }, + { type: 'reasoning-start', id: invalidItemId }, + { type: 'reasoning-delta', id: invalidItemId, delta: 'unsafe item summary' }, + { + type: 'reasoning-end', + id: invalidItemId, + providerMetadata: { + 'alibaba-token-plan-cn': { + itemId: invalidItemId, + reasoningSummary: [{ type: 'summary_text', text: 'unsafe item summary' }], + reasoningContent: null, + }, + }, + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); + const appended: AssistantMessage[] = []; + const connection = { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + } as const; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + if (message.type === 'assistant') appended.push(message); + }, + connection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + const events: SessionEvent[] = []; + for await (const event of backend.send({ turnId: 'turn-1', text: 'question', context: [] })) { + events.push(event); + } + + assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); + const parts = appended[0]?.thinking?.parts; + assert.deepEqual( + parts?.map((part) => [ + part.text, + (part.providerOptions?.makaResponses as { itemId?: unknown } | undefined)?.itemId, + ]), + [ + ['valid summary', 'reasoning-item-a'], + ['unsafe item summary', undefined], + ], + ); + + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-1', + turnId: 'turn-1', + now: () => 7, + newId: idGenerator(), + } as unknown as InvocationContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); + const recoveryModel = completionModel(); + const recoveryBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => recoveryModel, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + recoveryBackend.send({ + turnId: 'turn-2', + text: 'recover', + context: [], + runtimeContext, + }), + ); + + const prompt = compactPrompt(recoveryModel) as ModelMessage[]; + const assistant = prompt.find( + (message) => message.role === 'assistant' && Array.isArray(message.content), + ); + assert.ok(assistant && Array.isArray(assistant.content)); + assert.deepEqual( + assistant.content + .filter((part) => part.type === 'reasoning') + .map((part) => [ + part.text, + (part.providerOptions?.['alibaba-token-plan-cn'] as { itemId?: unknown } | undefined) + ?.itemId, + ]), + [['valid summary', 'reasoning-item-a']], + ); + }); + + test('Alibaba Responses isolates a same-id delta that arrives after item completion', async () => { + const connection = { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + } as const; + const model = new MockLanguageModelV4({ + doStream: { + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-item-a' }, + { type: 'reasoning-delta', id: 'reasoning-item-a', delta: 'valid summary' }, + { + type: 'reasoning-end', + id: 'reasoning-item-a', + providerMetadata: { + 'alibaba-token-plan-cn': { + itemId: 'reasoning-item-a', + reasoningSummary: [{ type: 'summary_text', text: 'valid summary' }], + reasoningContent: null, + }, + }, + }, + { type: 'reasoning-delta', id: 'reasoning-item-a', delta: 'late duplicate' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 2, text: 0, reasoning: 2 }, + }, + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }, + }); + const appended: AssistantMessage[] = []; + const firstBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + if (message.type === 'assistant') appended.push(message); + }, + connection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + const events: SessionEvent[] = []; + for await (const event of firstBackend.send({ + turnId: 'turn-1', + text: 'question', + context: [], + })) { + events.push(event); + } + + assert.deepEqual( + appended[0]?.thinking?.parts?.map((part) => [ + part.text, + (part.providerOptions?.makaResponses as { itemId?: unknown } | undefined)?.itemId, + ]), + [ + ['valid summary', 'reasoning-item-a'], + ['late duplicate', undefined], + ], + ); + + const ctx = { + sessionId: 'session-1', + invocationId: 'inv-1', + runId: 'run-1', + turnId: 'turn-1', + now: () => 7, + newId: idGenerator(), + } as unknown as InvocationContext; + const memory = createSessionEventMapMemory(); + const runtimeContext = events.map((event) => mapSessionEventToRuntimeEvent(event, ctx, memory)); + const recoveryModel = completionModel(); + const recoveryBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => recoveryModel, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + recoveryBackend.send({ + turnId: 'turn-2', + text: 'follow up', + context: [], + runtimeContext, + }), + ); + const prompt = compactPrompt(recoveryModel) as ModelMessage[]; + assert.equal( + prompt.some( + (message) => + message.role === 'assistant' && + Array.isArray(message.content) && + message.content.some( + (part) => part.type === 'reasoning' && part.text === 'valid summary', + ), + ), + true, + ); + }); + + test('Alibaba Responses skips reasoning it cannot safely replay', async () => { + const foreignSummary = 'summary issued by a different provider profile'; + const futureSummary = 'summary issued by a future durable state version'; + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + const runtimeContext: RuntimeEvent[] = [ + runtimeEvent({ + id: 'e1', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'summary without a provider item identity', + }, + refs: { providerEventId: 'm1' }, + }), + runtimeEvent({ + id: 'e2', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: foreignSummary, + providerOptions: { + makaResponses: { + version: 1, + profile: 'alibaba-token-plan', + itemId: 'foreign-reasoning-item', + summaryPartLengths: [foreignSummary.length], + }, + }, + }, + refs: { providerEventId: 'm2' }, + }), + runtimeEvent({ + id: 'e3', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: futureSummary, + providerOptions: { + makaResponses: { + version: 2, + profile: 'alibaba-token-plan-cn', + itemId: 'future-reasoning-item', + summaryPartLengths: [futureSummary.length], + }, + }, + }, + refs: { providerEventId: 'm3' }, + }), + ]; + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + runtimeContext, + }), + ); + + const prompt = compactPrompt(model) as ModelMessage[]; + assert.equal( + prompt.some( + (message) => + message.role === 'assistant' && + Array.isArray(message.content) && + message.content.some((part) => part.type === 'reasoning'), + ), + false, + ); + }); + + test('Alibaba Responses rejects malformed state owned by its profile', async () => { + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => completionModel(), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + const runtimeContext: RuntimeEvent[] = [ + runtimeEvent({ + id: 'e1', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'thinking', + text: 'current-profile reasoning with a widened state', + providerOptions: { + makaResponses: { + version: 1, + profile: 'alibaba-token-plan-cn', + itemId: 'reasoning-item', + raw: 'must not persist', + }, + }, + }, + refs: { providerEventId: 'm1' }, + }), + ]; + + await assert.rejects( + drain( + backend.send({ + turnId: 'turn-current', + text: 'follow up', + context: [], + runtimeContext, + }), + ), + /Malformed durable plaintext Responses reasoning state/, + ); + }); + test('passes DeepSeek max reasoning through as the provider-native effort', async () => { let requestBody: Record | undefined; const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { diff --git a/packages/runtime/src/__tests__/model-adapter-onerror.test.ts b/packages/runtime/src/__tests__/model-adapter-onerror.test.ts index 0b2dd3de7c..c789c6548a 100644 --- a/packages/runtime/src/__tests__/model-adapter-onerror.test.ts +++ b/packages/runtime/src/__tests__/model-adapter-onerror.test.ts @@ -44,6 +44,21 @@ function newAdapter(): ModelAdapter { }); } +function newAlibabaAdapter(): ModelAdapter { + return new ModelAdapter({ + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'test', + modelId: 'qwen3.8-max', + modelFactory: () => ({}), + newId: () => 'id', + now: () => 0, + }); +} + describe('settleModelStepOutcome', () => { test('explicit stream failure takes precedence over finish metadata', () => { const failure = { @@ -65,9 +80,179 @@ describe('settleModelStepOutcome', () => { if (outcome.kind !== 'retryable-failure') return; assert.equal(outcome.failure, failure); }); + + test('classifies a raw error finish without widening provider retry policy', () => { + const outcome = settleModelStepOutcome({ + aborted: false, + sawFinish: true, + finishReason: 'error', + rawFinishReason: '503', + request: {}, + }); + + assert.equal(outcome.kind, 'terminal-failure'); + if (outcome.kind !== 'terminal-failure') return; + assert.equal(outcome.failure.kind, 'provider_unavailable'); + assert.equal(outcome.failure.code, '503'); + assert.equal(outcome.failure.retryable, false); + }); }); describe('ModelAdapter.startStream onError', () => { + test('preserves an already-emitted failure when reasoning flush has no final metadata', async () => { + const providerError = new APICallError({ + message: 'rate limited', + url: 'https://provider.invalid/v1/responses', + requestBodyValues: {}, + statusCode: 429, + responseHeaders: { 'retry-after-ms': '2500' }, + }); + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { type: 'reasoning-delta', id: 'reasoning-1', delta: 'partial reasoning' }, + { type: 'error', error: providerError }, + // This test owns the ModelAdapter boundary sequence. Separate pinned + // SDK fixtures below prove which raw SSE terminal events produce the + // metadata-less reasoning trailer. + { type: 'reasoning-end', id: 'reasoning-1' }, + { + type: 'finish', + finishReason: { unified: 'error', raw: 'provider_error' }, + usage: ZERO_USAGE, + }, + ]), + }), + }); + const result = await newAlibabaAdapter().startStream({ + model, + messages: [{ role: 'user', content: 'hi' }], + tools: {}, + activeTools: [], + onStreamActivity: () => {}, + abortSignal: new AbortController().signal, + repairToolCall: async () => null, + }); + + const failures = []; + for await (const event of result.events) { + if (event.kind === 'error') { + failures.push(event.failure); + // AiSdkBackend stops consuming after the first error. ModelAdapter + // must settle outcome before yielding this deferred validation error. + break; + } + } + + assert.deepEqual(failures, [ + { + type: 'model_failure', + kind: 'rate_limit', + code: '429', + message: 'Rate limit exceeded', + retryable: true, + retryAfterMs: 2500, + }, + ]); + const outcome = await requireAlreadySettled(result.outcome); + assert.equal(outcome.kind, 'retryable-failure'); + if (outcome.kind !== 'retryable-failure') return; + assert.deepEqual(outcome.failure, failures[0]); + }); + + test('fails closed when a successful reasoning stream has no final metadata', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { type: 'reasoning-delta', id: 'reasoning-1', delta: 'partial reasoning' }, + // A completed response without output_item.done reaches Maka as the + // same metadata-less flush trailer as a failed response. + { type: 'reasoning-end', id: 'reasoning-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: ZERO_USAGE, + }, + ]), + }), + }); + const result = await newAlibabaAdapter().startStream({ + model, + messages: [{ role: 'user', content: 'hi' }], + tools: {}, + activeTools: [], + onStreamActivity: () => {}, + abortSignal: new AbortController().signal, + repairToolCall: async () => null, + }); + + const failures = []; + for await (const event of result.events) { + if (event.kind === 'error') { + failures.push(event.failure); + break; + } + } + + assert.deepEqual(failures, [ + { + type: 'model_failure', + kind: 'unknown', + message: 'Plaintext Responses reasoning item is missing final summary metadata', + retryable: false, + }, + ]); + const outcome = await requireAlreadySettled(result.outcome); + assert.equal(outcome.kind, 'terminal-failure'); + if (outcome.kind !== 'terminal-failure') return; + assert.deepEqual(outcome.failure, failures[0]); + }); + + test('classifies a raw provider error finish ahead of an unfinalized reasoning trailer', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: convertArrayToReadableStream([ + { type: 'stream-start', warnings: [] }, + { type: 'reasoning-start', id: 'reasoning-1' }, + { type: 'reasoning-delta', id: 'reasoning-1', delta: 'partial reasoning' }, + { type: 'reasoning-end', id: 'reasoning-1' }, + { + type: 'finish', + finishReason: { unified: 'error', raw: 'rate_limit_exceeded' }, + usage: ZERO_USAGE, + }, + ]), + }), + }); + const result = await newAlibabaAdapter().startStream({ + model, + messages: [{ role: 'user', content: 'hi' }], + tools: {}, + activeTools: [], + onStreamActivity: () => {}, + abortSignal: new AbortController().signal, + repairToolCall: async () => null, + }); + + for await (const _event of result.events) void _event; + const outcome = await result.outcome; + + assert.equal(outcome.kind, 'terminal-failure'); + if (outcome.kind !== 'terminal-failure') return; + assert.deepEqual(outcome.failure, { + type: 'model_failure', + kind: 'rate_limit', + retryable: false, + message: 'Rate limit exceeded', + code: 'rate_limit_exceeded', + }); + assert.equal(outcome.usage?.rawFinishReason, 'rate_limit_exceeded'); + }); + test('normalizes provider retry eligibility and Retry-After at the adapter boundary', async () => { const model = new MockLanguageModelV4({ doStream: async () => { @@ -332,3 +517,10 @@ async function settle( for await (const _event of result.events) void _event; return await result.outcome; } + +async function requireAlreadySettled(promise: Promise): Promise { + const pending = Symbol('pending'); + const value = await Promise.race([promise, Promise.resolve(pending)]); + assert.notEqual(value, pending, 'model outcome must settle before the consumer stops iterating'); + return value as T; +} diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 99b08f8991..d6a9871e3c 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -170,6 +170,136 @@ describe('ModelAdapter stream and error normalization', () => { }); }); + test('supports summary-item Responses reasoning replay for Alibaba Token Plan', () => { + const adapter = new ModelAdapter({ + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + + assert.deepEqual(adapter.runtimeEventReplaySupport(), { + toolCalls: true, + toolResults: true, + providerExecutedTools: false, + signedThinking: false, + unsignedThinking: false, + responsesReasoning: { + kind: 'plaintext-item', + profile: 'alibaba-token-plan-cn', + providerOptionsKey: 'alibaba-token-plan-cn', + }, + }); + }); + + test('normalizes Alibaba stream item ids into bounded durable state from item start', () => { + const providerType = 'alibaba-token-plan-cn'; + const adapter = new ModelAdapter({ + connection: { slug: providerType, providerType, defaultModel: 'qwen3.8-max' }, + apiKey: 'token', + modelId: 'qwen3.8-max', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + type Chunk = Parameters[0]; + const providerOptions = { + makaResponses: { + version: 1, + profile: providerType, + itemId: 'alibaba-reasoning-item', + summaryPartLengths: [7], + }, + }; + assert.deepEqual( + adapter.translateChunk({ type: 'reasoning-start', id: 'alibaba-reasoning-item' } as Chunk), + [{ kind: 'thinking', text: '', reasoningItemId: 'alibaba-reasoning-item' }], + ); + assert.deepEqual( + adapter.translateChunk({ + type: 'reasoning-delta', + id: 'alibaba-reasoning-item', + delta: 'summary', + } as Chunk), + [{ kind: 'thinking', text: 'summary', reasoningItemId: 'alibaba-reasoning-item' }], + ); + assert.deepEqual( + adapter.translateChunk({ + type: 'reasoning-end', + id: 'alibaba-reasoning-item', + providerMetadata: { + [providerType]: { + itemId: 'alibaba-reasoning-item', + reasoningSummary: [{ type: 'summary_text', text: 'summary' }], + }, + }, + } as Chunk), + [ + { + kind: 'thinking', + text: '', + providerOptions, + reasoningItemId: 'alibaba-reasoning-item', + reasoningSummaryText: 'summary', + }, + ], + ); + assert.throws( + () => + adapter.translateChunk({ + type: 'reasoning-end', + id: 'unfinished-flush', + } as Chunk), + /missing final summary metadata/, + ); + assert.throws( + () => + adapter.translateChunk({ + type: 'reasoning-end', + id: 'missing-final-summary', + providerMetadata: { + [providerType]: { itemId: 'missing-final-summary' }, + }, + } as Chunk), + /missing final summary metadata/, + ); + }); + + test('keeps DeepSeek plaintext replay on the main content-only behavior', () => { + const adapter = new ModelAdapter({ + connection: { slug: 'deepseek', providerType: 'deepseek', defaultModel: 'deepseek-v4-flash' }, + apiKey: 'token', + modelId: 'deepseek-v4-flash', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + type Chunk = Parameters[0]; + + assert.deepEqual( + adapter.translateChunk({ + type: 'reasoning-delta', + id: 'deepseek-reasoning-item', + delta: 'plaintext reasoning', + } as Chunk), + [{ kind: 'thinking', text: 'plaintext reasoning' }], + ); + assert.deepEqual( + adapter.translateChunk({ + type: 'reasoning-end', + id: 'deepseek-reasoning-item', + providerMetadata: { deepseek: { itemId: 'deepseek-reasoning-item' } }, + } as Chunk), + [], + ); + }); + test('translates provider text, reasoning, tool calls, and errors into ModelStreamEvents', () => { const adapter = newAdapter(); type Chunk = Parameters[0]; diff --git a/packages/runtime/src/__tests__/open-responses-compatibility.test.ts b/packages/runtime/src/__tests__/open-responses-compatibility.test.ts new file mode 100644 index 0000000000..12230ebd9a --- /dev/null +++ b/packages/runtime/src/__tests__/open-responses-compatibility.test.ts @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createOpenResponsesCompatibilityFinalizer } from '../open-responses-compatibility.js'; + +test('applies the declared Open Responses body policies', () => { + const finalize = createOpenResponsesCompatibilityFinalizer('alibaba-token-plan'); + assert.ok(finalize); + const tool = { type: 'function', name: 'lookup' }; + assert.deepEqual(finalize({ model: 'qwen3.8-max', store: true, tool_choice: 'auto' }), { + model: 'qwen3.8-max', + store: false, + tool_choice: 'auto', + }); + assert.deepEqual(finalize({ model: 'qwen3.8-max' }), { + model: 'qwen3.8-max', + store: false, + }); + assert.deepEqual(finalize({ tools: [tool], tool_choice: 'required' }), { + tools: [tool], + tool_choice: 'required', + store: false, + }); + assert.deepEqual( + finalize({ + tools: [tool], + tool_choice: { type: 'allowed_tools', mode: 'required', tools: [tool] }, + }), + { + tools: [tool], + tool_choice: { type: 'allowed_tools', mode: 'required', tools: [tool] }, + store: false, + }, + ); + assert.throws( + () => finalize({ tools: [], tool_choice: 'required' }), + /requires exactly one tool/, + ); + assert.throws( + () => + finalize({ + tools: [tool], + tool_choice: { type: 'allowed_tools', mode: 'required', tools: [] }, + }), + /requires exactly one tool/, + ); + assert.throws( + () => finalize({ tools: [tool], tool_choice: tool }), + /does not support this tool_choice object/, + ); +}); 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 f28f382ce6..34c1f02683 100644 --- a/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts +++ b/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts @@ -50,7 +50,12 @@ const ANSWER = 'No — 91 is 7 x 13.'; * 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[], answer = ANSWER): string { +function plaintextReasoningStream( + deltas: string[], + answer = ANSWER, + finalSummary: Array<{ type: 'summary_text'; text: string }> = [], + model = 'deepseek-v4-flash', +): string { const events: Array> = [ { type: 'response.created', response: { id: 'r' } }, { @@ -81,7 +86,7 @@ function deepseekReasoningStream(deltas: string[], answer = ANSWER): string { id: ITEM_ID, status: 'completed', content: [{ type: 'reasoning_text', text: deltas.join('') }], - summary: [], + summary: finalSummary, }, }, { @@ -119,7 +124,7 @@ function deepseekReasoningStream(deltas: string[], answer = ANSWER): string { id: 'r', object: 'response', created_at: 0, - model: 'deepseek-v4-flash', + model, status: 'completed', output: [], usage: { input_tokens: 1, output_tokens: 1 }, @@ -177,6 +182,55 @@ function standardFunctionCallStream(): string { return `${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`; } +function unfinalizedReasoningStream(terminal: 'completed' | 'failed'): string { + const response = { + id: 'r', + object: 'response', + created_at: 0, + model: 'qwen3.8-max', + status: terminal, + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + ...(terminal === 'failed' + ? { error: { code: 'rate_limit_exceeded', message: 'rate limited' } } + : {}), + }; + const events = [ + { 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: [] }, + }, + { + type: 'response.reasoning_text.delta', + content_index: 0, + delta: 'unfinished reasoning', + item_id: ITEM_ID, + output_index: 0, + }, + { type: `response.${terminal}`, response }, + ]; + return `${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`; +} + +async function alibabaStreamParts(body: string) { + const connection = conn('alibaba-token-plan-cn'); + const model = getAIModel({ + connection, + apiKey: 'test-key', + modelId: 'qwen3.8-max', + fetch: sseFetch(body), + }); + const { stream } = await model.doStream({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + providerOptions: buildProviderOptions(connection, 'qwen3.8-max', 'high'), + }); + const parts = []; + for await (const part of stream) parts.push(part); + return parts; +} + /** * 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 @@ -226,9 +280,51 @@ async function streamParts( } describe('open responses plaintext reasoning', () => { + test('Alibaba raw SSE content deltas match the final summary metadata', async () => { + const deltas = ['Inspect the request. ', 'Call the Maka tool.']; + const summary = [{ type: 'summary_text' as const, text: deltas.join('') }]; + const parts = await alibabaStreamParts( + plaintextReasoningStream(deltas, ANSWER, summary, 'qwen3.8-max'), + ); + const streamed = parts + .filter((part) => part.type === 'reasoning-delta') + .map((part) => part.delta) + .join(''); + const reasoningEnd = parts.find((part) => part.type === 'reasoning-end'); + assert.ok(reasoningEnd && reasoningEnd.type === 'reasoning-end'); + const provider = reasoningEnd.providerMetadata?.['alibaba-token-plan-cn'] as + | { reasoningSummary?: Array<{ type: string; text: string }> } + | undefined; + assert.deepEqual(provider?.reasoningSummary, [ + { type: 'summary_text', text: 'Inspect the request. Call the Maka tool.' }, + ]); + assert.equal(streamed, provider?.reasoningSummary?.map((part) => part.text).join('')); + }); + + test('the pinned SDK flushes an unfinalized item without provider metadata', async () => { + const parts = await alibabaStreamParts(unfinalizedReasoningStream('completed')); + const reasoningEnd = parts.find((part) => part.type === 'reasoning-end'); + const finish = parts.find((part) => part.type === 'finish'); + + assert.ok(reasoningEnd); + assert.equal(reasoningEnd.providerMetadata, undefined); + assert.equal(finish?.finishReason.unified, 'stop'); + }); + + test('the pinned SDK keeps response.failed ahead of its unfinalized trailer', async () => { + const parts = await alibabaStreamParts(unfinalizedReasoningStream('failed')); + const reasoningEnd = parts.find((part) => part.type === 'reasoning-end'); + const finish = parts.find((part) => part.type === 'finish'); + + assert.ok(reasoningEnd); + assert.equal(reasoningEnd.providerMetadata, undefined); + assert.equal(finish?.finishReason.unified, 'error'); + assert.equal(finish?.finishReason.raw, 'rate_limit_exceeded'); + }); + 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 parts = await streamParts('deepseek', sseFetch(deepseekReasoningStream(deltas))); + const parts = await streamParts('deepseek', sseFetch(plaintextReasoningStream(deltas))); assert.equal(parts.reasoning, deltas.join('')); assert.equal(parts.text, ANSWER); }); @@ -239,7 +335,7 @@ describe('open responses plaintext reasoning', () => { // dropping message frames wholesale would otherwise leave the suite green. const parts = await streamParts( 'deepseek', - sseFetch(deepseekReasoningStream(['thinking'], 'The answer is 42.')), + sseFetch(plaintextReasoningStream(['thinking'], 'The answer is 42.')), ); assert.equal(parts.text, 'The answer is 42.'); }); @@ -251,7 +347,7 @@ describe('open responses plaintext reasoning', () => { // 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)); + const parts = await streamParts('deepseek', sseFetch(plaintextReasoningStream(deltas), 7)); assert.equal(parts.reasoning, deltas.join('')); assert.equal(parts.text, ANSWER); }); @@ -260,7 +356,7 @@ describe('open responses plaintext reasoning', () => { // 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 parts = await streamParts('xai', sseFetch(deepseekReasoningStream(['ignored']))); + const parts = await streamParts('xai', sseFetch(plaintextReasoningStream(['ignored']))); assert.equal(parts.reasoning, ''); assert.equal(parts.text, ANSWER); }); diff --git a/packages/runtime/src/__tests__/provider-contract-matrix.test.ts b/packages/runtime/src/__tests__/provider-contract-matrix.test.ts index 4f3b95ffe5..0da26ce942 100644 --- a/packages/runtime/src/__tests__/provider-contract-matrix.test.ts +++ b/packages/runtime/src/__tests__/provider-contract-matrix.test.ts @@ -23,6 +23,7 @@ import { after, describe, test } from 'node:test'; import type { LlmConnection } from '@maka/core/llm-connections'; import { PROVIDER_CONTRACT_MATRIX_PLAN, + listProviderContractCells, type ProviderContractDiscoveryPlan, type ProviderContractRow, type ProviderContractGeneratedCell, @@ -49,6 +50,16 @@ const plan = PROVIDER_CONTRACT_MATRIX_PLAN; after(closeAllJsonServers); +test('provider override cells and executable bindings are a bijection', () => { + const plannedKeys = listProviderContractCells(plan) + .flatMap(({ cell }) => (cell.state === 'override' ? [cell.overrideKey] : [])) + .sort(); + const bindingKeys = PROVIDER_CONTRACT_OVERRIDE_BINDINGS.flatMap(({ keys }) => keys).sort(); + assert.deepEqual(duplicateValues(plannedKeys), [], 'override cells must be unique'); + assert.deepEqual(duplicateValues(bindingKeys), [], 'override bindings must be unique'); + assert.deepEqual(bindingKeys, plannedKeys); +}); + describe('provider conformance matrix — override cells execute their bound contract', () => { for (const binding of PROVIDER_CONTRACT_OVERRIDE_BINDINGS) { test(`${binding.keys.join(' + ')} · ${binding.title}`, async () => { @@ -57,6 +68,10 @@ describe('provider conformance matrix — override cells execute their bound con } }); +function duplicateValues(values: readonly string[]): string[] { + return values.filter((value, index) => values.indexOf(value) !== index); +} + describe('provider conformance matrix — discovery', () => { for (const row of plan.rows) { const cell = row.cells.discovery; diff --git a/packages/runtime/src/__tests__/provider-contract-matrix.ts b/packages/runtime/src/__tests__/provider-contract-matrix.ts index 274c7851fb..449cdb8f39 100644 --- a/packages/runtime/src/__tests__/provider-contract-matrix.ts +++ b/packages/runtime/src/__tests__/provider-contract-matrix.ts @@ -51,6 +51,7 @@ import { type ProviderRuntimeAdapter, type ProviderType, } from '@maka/core/provider-registry'; +import { resolveRuntimeProviderAdapter } from '../provider-runtime-policy.js'; export const PROVIDER_CONTRACT_DIMENSIONS = [ 'discovery', @@ -214,7 +215,7 @@ function usesOpenAiResponsesWire( def: ProviderDefaults, modelId: string, ): boolean { - const adapter = def.runtimeAdapter; + const adapter = resolveRuntimeProviderAdapter(def.runtimeAdapter); const supportsResponses = adapter.kind === 'openai' || (adapter.kind === 'openai-compatible' && adapter.responses !== undefined); @@ -389,7 +390,7 @@ function reasoningReplayCell( providerType: ProviderType, def: ProviderDefaults, ): ProviderContractCell { - const adapter = def.runtimeAdapter; + const adapter = resolveRuntimeProviderAdapter(def.runtimeAdapter); if (adapter.kind === 'unavailable') { return { state: 'not-applicable', @@ -434,17 +435,12 @@ function reasoningReplayCell( }, }; } - if ( - providerType === 'volcengine-agent-plan' && - adapter.kind === 'openai' && - adapter.apiProtocol === 'openai-responses' - ) { + if (adapter.kind === 'openai' && adapter.apiProtocol === 'openai-responses') { return { state: 'override', dimension: 'reasoning-replay', overrideKey: overrideKeyFor(providerType, 'reasoning-replay'), - contract: - 'Stateless OpenAI Responses reasoning items retain their encrypted content across Maka-owned durable replay', + contract: 'Native OpenAI Responses reasoning items retain their provider continuation state', }; } // Native Anthropic / OpenAI / Google / Cohere SDKs own signed reasoning replay diff --git a/packages/runtime/src/__tests__/provider-contract-overrides.ts b/packages/runtime/src/__tests__/provider-contract-overrides.ts index 6a25c8b36a..ca7c049a2f 100644 --- a/packages/runtime/src/__tests__/provider-contract-overrides.ts +++ b/packages/runtime/src/__tests__/provider-contract-overrides.ts @@ -97,6 +97,42 @@ export const PROVIDER_CONTRACT_OVERRIDE_BINDINGS: readonly ProviderContractOverr title: 'ZenMux replays signed reasoning details in the streamed runtime tool loop', run: runZenMuxSignedReasoningReplay, }, + { + keys: ['deepseek:reasoning-replay'], + title: 'DeepSeek replays plaintext reasoning content on its Responses wire', + run: () => + runOpenAIResponsesWire({ + providerType: 'deepseek', + slug: 'deepseek', + name: 'DeepSeek', + basePath: '/v1', + modelId: 'deepseek-v4-flash', + apiKey: 'deepseek-test-key', + plaintextReasoning: true, + }), + }, + { + keys: ['xai:reasoning-replay', 'xai-oauth:reasoning-replay'], + title: 'xAI API-key and OAuth paths retain encrypted Responses reasoning', + run: async () => { + for (const input of [ + { providerType: 'xai' as const, slug: 'xai', apiKey: 'xai-test-key' }, + { + providerType: 'xai-oauth' as const, + slug: 'xai-oauth', + apiKey: 'xai-oauth-test-token', + }, + ]) { + await runOpenAIResponsesWire({ + ...input, + name: input.slug, + basePath: `/${input.slug}/v1`, + modelId: 'grok-4.5', + statelessReasoning: true, + }); + } + }, + }, { keys: [ 'openai-responses-compatible:exact-model-id', @@ -133,8 +169,138 @@ export const PROVIDER_CONTRACT_OVERRIDE_BINDINGS: readonly ProviderContractOverr statelessReasoning: true, }), }, + { + keys: ['alibaba-token-plan-cn:reasoning-replay', 'alibaba-token-plan:reasoning-replay'], + title: 'Alibaba Token Plan replays plaintext summary items on its Responses wire', + run: runAlibabaTokenPlanResponsesWire, + }, ]; +async function runAlibabaTokenPlanResponsesWire(): Promise { + for (const input of [ + { + providerType: 'alibaba-token-plan-cn' as const, + slug: 'alibaba-token-plan-cn', + basePath: '/cn/compatible-mode/v1', + apiKey: 'alibaba-token-plan-cn-key', + }, + { + providerType: 'alibaba-token-plan' as const, + slug: 'alibaba-token-plan', + basePath: '/global/compatible-mode/v1', + apiKey: 'alibaba-token-plan-global-key', + }, + ]) { + const modelId = 'qwen3.8-max'; + const requestBodies: Array> = []; + const server = await startJsonServer(async (request, response) => { + assert.equal(request.method, 'POST'); + assert.equal(request.url, `${input.basePath}/responses`); + assert.equal(request.headers.authorization, `Bearer ${input.apiKey}`); + requestBodies.push(JSON.parse(await readBody(request)) as Record); + if (requestBodies.length === 1) { + respondJson(response, 200, { + id: 'resp_alibaba_tool', + object: 'response', + created_at: 1, + status: 'completed', + model: modelId, + output: [ + { + type: 'reasoning', + id: 'rs_alibaba_tool', + summary: [{ type: 'summary_text', text: 'Use echo.' }], + }, + { + type: 'function_call', + id: 'fc_alibaba_echo', + call_id: 'call_alibaba_echo', + name: 'echo', + arguments: '{"text":"hello"}', + status: 'completed', + }, + ], + usage: { input_tokens: 8, output_tokens: 4, total_tokens: 12 }, + }); + return; + } + respondJson(response, 200, { + id: 'resp_alibaba_final', + object: 'response', + created_at: 2, + status: 'completed', + model: modelId, + output: [ + { + type: 'message', + id: 'msg_alibaba_final', + status: 'completed', + role: 'assistant', + content: [ + { type: 'output_text', text: 'Echoed hello.', annotations: [], logprobs: [] }, + ], + }, + ], + usage: { input_tokens: 14, output_tokens: 3, total_tokens: 17 }, + }); + }); + const connection: LlmConnection = { + slug: input.slug, + name: input.slug, + providerType: input.providerType, + baseUrl: `${server.url}${input.basePath}`, + defaultModel: modelId, + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + const result = await generateText({ + model: getAIModel({ connection, apiKey: input.apiKey, modelId }), + prompt: 'Call echo with hello.', + providerOptions: buildProviderOptions(connection, modelId), + stopWhen: isStepCount(2), + tools: { + echo: tool({ + description: 'Echo text', + inputSchema: z.object({ text: z.string() }), + execute: async ({ text }) => ({ echoed: text }), + }), + }, + }); + + assert.deepEqual( + requestBodies.map((body) => body.model), + [modelId, modelId], + ); + assert.deepEqual( + requestBodies.map((body) => body.store), + [false, false], + ); + assert.deepEqual( + (requestBodies[1].input as Array>).find( + ({ type }) => type === 'reasoning', + ), + { + type: 'reasoning', + id: 'rs_alibaba_tool', + summary: [{ type: 'summary_text', text: 'Use echo.' }], + }, + ); + assert.deepEqual( + (requestBodies[1].input as Array>).find( + ({ type }) => type === 'function_call_output', + ), + { + type: 'function_call_output', + call_id: 'call_alibaba_echo', + output: '{"echoed":"hello"}', + }, + ); + assert.equal(result.text, 'Echoed hello.'); + } +} + async function runCloudflareDiscovery(): Promise { const server = await startJsonServer((request, response) => { assert.equal(request.method, 'GET'); @@ -883,8 +1049,19 @@ async function runOpenAIResponsesWire(input: { modelId: string; apiKey: string; statelessReasoning?: boolean; + plaintextReasoning?: boolean; }): Promise { - const { providerType, slug, name, basePath, modelId, apiKey, statelessReasoning } = input; + const { + providerType, + slug, + name, + basePath, + modelId, + apiKey, + statelessReasoning, + plaintextReasoning, + } = input; + const hasReasoning = statelessReasoning || plaintextReasoning; const requestBodies: Array> = []; const server = await startJsonServer(async (request, response) => { assert.equal(request.method, 'POST'); @@ -908,7 +1085,16 @@ async function runOpenAIResponsesWire(input: { encrypted_content: 'encrypted-relay-reasoning', }, ] - : []), + : plaintextReasoning + ? [ + { + type: 'reasoning', + id: 'rs_relay_tool', + summary: [], + content: [{ type: 'reasoning_text', text: 'Use echo.' }], + }, + ] + : []), { type: 'function_call', id: 'fc_relay_echo', @@ -954,7 +1140,7 @@ async function runOpenAIResponsesWire(input: { const result = await generateText({ model: getAIModel({ connection, apiKey, modelId }), prompt: 'Call echo with hello.', - ...(statelessReasoning ? { providerOptions: buildProviderOptions(connection, modelId) } : {}), + ...(hasReasoning ? { providerOptions: buildProviderOptions(connection, modelId) } : {}), stopWhen: isStepCount(2), tools: { echo: tool({ @@ -984,6 +1170,19 @@ async function runOpenAIResponsesWire(input: { }, ); } + if (plaintextReasoning) { + assert.deepEqual( + (requestBodies[1].input as Array>).find( + ({ type }) => type === 'reasoning', + ), + { + type: 'reasoning', + id: 'rs_relay_tool', + summary: [], + content: [{ type: 'reasoning_text', text: 'Use echo.' }], + }, + ); + } assert.deepEqual( (requestBodies[1].input as Array>).find( ({ type }) => type === 'function_call_output', diff --git a/packages/runtime/src/__tests__/request-customization-fetch.test.ts b/packages/runtime/src/__tests__/request-customization-fetch.test.ts index cca34784aa..5dda1aa769 100644 --- a/packages/runtime/src/__tests__/request-customization-fetch.test.ts +++ b/packages/runtime/src/__tests__/request-customization-fetch.test.ts @@ -119,4 +119,31 @@ describe('createRequestCustomizationFetch', () => { /Extra request body conflicts/, ); }); + + test('applies a provider finalizer after caller body overlays', async () => { + let captured: Request | undefined; + const fetch = createRequestCustomizationFetch( + async (input, init) => { + captured = new Request(input, init); + return Response.json({ ok: true }); + }, + { + bodyOverlay: { store: true }, + finalizeBody: (body) => ({ ...body, store: false }), + }, + ); + + await fetch('https://provider.invalid/responses', { + method: 'POST', + headers: { 'content-length': '999', 'content-type': 'application/json' }, + body: new TextEncoder().encode(JSON.stringify({ model: 'qwen3.8-max' })).buffer, + }); + + assert.deepEqual(await captured?.json(), { model: 'qwen3.8-max', store: false }); + assert.equal(captured?.headers.get('content-length'), null); + await assert.rejects( + fetch('https://provider.invalid/responses', { method: 'POST', body: 'not-json' }), + /finalizer requires a JSON object request body/, + ); + }); }); diff --git a/packages/runtime/src/__tests__/responses-reasoning-state.test.ts b/packages/runtime/src/__tests__/responses-reasoning-state.test.ts new file mode 100644 index 0000000000..96753ad4aa --- /dev/null +++ b/packages/runtime/src/__tests__/responses-reasoning-state.test.ts @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + decodePlaintextResponsesReasoningState, + plaintextResponsesReasoningProviderOptions, + replayPlaintextResponsesProviderOptions, + responsesReasoningItemId, +} from '../responses-reasoning-state.js'; + +test('round-trips one bounded versioned plaintext Responses item identity', () => { + const options = plaintextResponsesReasoningProviderOptions( + 'reasoning-item-1', + 'alibaba-token-plan-cn', + ['reasoning summary'], + ); + assert.deepEqual(options, { + makaResponses: { + version: 1, + profile: 'alibaba-token-plan-cn', + itemId: 'reasoning-item-1', + summaryPartLengths: [17], + }, + }); + assert.deepEqual(decodePlaintextResponsesReasoningState(options), { + kind: 'valid', + state: { + version: 1, + profile: 'alibaba-token-plan-cn', + itemId: 'reasoning-item-1', + summaryPartLengths: [17], + }, + }); + assert.equal(responsesReasoningItemId(options), 'reasoning-item-1'); +}); + +test('rejects malformed, widened, and unsafe plaintext Responses state', () => { + for (const makaResponses of [ + { version: '2', profile: 'alibaba-token-plan-cn', itemId: 'item' }, + { version: 1, profile: '', itemId: 'item' }, + { version: 1, profile: 'bad\nprofile', itemId: 'item' }, + { version: 1, profile: 'alibaba-token-plan-cn', itemId: '' }, + { version: 1, profile: 'alibaba-token-plan-cn', itemId: 'bad\nitem' }, + { + version: 1, + profile: 'alibaba-token-plan-cn', + itemId: 'item', + summaryPartLengths: [4], + raw: 'provider-body', + }, + ]) { + assert.equal(decodePlaintextResponsesReasoningState({ makaResponses }).kind, 'malformed'); + } + assert.deepEqual(decodePlaintextResponsesReasoningState(undefined), { kind: 'missing' }); + assert.deepEqual( + decodePlaintextResponsesReasoningState({ + makaResponses: { + version: 2, + profile: 'another-provider', + itemId: 'item', + }, + }), + { kind: 'unsupported-version', version: 2 }, + ); +}); + +test('degrades a well-formed state version that this Runtime cannot replay', () => { + assert.deepEqual( + decodePlaintextResponsesReasoningState({ + makaResponses: { + version: 2, + profile: 'alibaba-token-plan-cn', + itemId: 'item', + summaryPartLengths: [4], + }, + }), + { kind: 'unsupported-version', version: 2 }, + ); +}); + +test('reconstructs provider-native summary parts', () => { + const summary = { + version: 1, + profile: 'alibaba-token-plan-cn', + itemId: 'summary-item', + summaryPartLengths: [10, 7], + } as const; + assert.deepEqual( + replayPlaintextResponsesProviderOptions({ + providerOptionsKey: 'alibaba-token-plan-cn', + state: summary, + text: 'reasoning summary', + }), + { + 'alibaba-token-plan-cn': { + itemId: 'summary-item', + reasoningSummary: [ + { type: 'summary_text', text: 'reasoning ' }, + { type: 'summary_text', text: 'summary' }, + ], + reasoningContent: null, + }, + }, + ); +}); + +test('rejects summary boundaries that disagree with canonical text', () => { + const state = { + version: 1, + profile: 'alibaba-token-plan-cn', + itemId: 'summary-item', + summaryPartLengths: [8], + } as const; + assert.throws( + () => + replayPlaintextResponsesProviderOptions({ + providerOptionsKey: 'alibaba-token-plan-cn', + state: { ...state, summaryPartLengths: [3] }, + text: 'expected', + }), + /summary boundaries do not match text/, + ); +}); + +test('keeps encrypted OpenAI item identity readable for shared step grouping', () => { + assert.equal( + responsesReasoningItemId({ + openai: { itemId: 'openai-item', reasoningEncryptedContent: 'encrypted' }, + }), + 'openai-item', + ); +}); diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index 99b1bc93cf..be0b477dc0 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -26,6 +26,10 @@ import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { thinkingVariantsForModel } from '@maka/core/model-thinking'; import { buildProviderOptions, getAIModel } from '../model-factory.js'; import { resolveModelRuntime } from '../model-runtime.js'; +import { + RUNTIME_PROVIDER_PROFILE_IDS, + resolveRuntimeProviderAdapter, +} from '../provider-runtime-policy.js'; import { lowerModelTools } from '../model-adapter.js'; import { openAiCodexCompactionMessages } from '../openai-codex-history-compactor.js'; import { openAiResponsesBaseUrl, openResponsesUrl } from '../provider-urls.js'; @@ -51,13 +55,21 @@ function openAiNamespace(options: Record): Record { - test('keeps Qwen3.8 Max on Token Plan Chat until the provider adapter supports Responses', () => { + test('routes only Qwen3.8 Max through Token Plan Responses', () => { for (const providerType of ['alibaba-token-plan-cn', 'alibaba-token-plan'] as const) { assert.equal( resolveModelRuntime({ providerType }, 'qwen3.8-max').wire, - 'openai-chat', + 'openai-responses', providerType, ); + assert.equal(resolveModelRuntime({ providerType }, 'qwen3.7-max').wire, 'openai-chat'); + assert.equal( + resolveModelRuntime( + { providerType, models: [{ id: 'qwen3.8-max', apiProtocol: 'openai-chat' }] }, + 'qwen3.8-max', + ).wire, + 'openai-chat', + ); } }); @@ -147,6 +159,26 @@ describe('responses wire contract', () => { kind: 'responses', contract: { adapter: 'open-responses', reasoningReplay: 'plaintext-content' }, }); + assert.equal(deepseek.responsesProviderOptionsKey, undefined); + assert.equal(deepseek.responsesReplayProfile, undefined); + + const alibaba = resolveModelRuntime({ providerType: 'alibaba-token-plan-cn' }, 'qwen3.8-max'); + assert.deepEqual(alibaba.reasoningReplay, { + kind: 'responses', + contract: { + adapter: 'open-responses', + reasoningReplay: 'plaintext-summary', + compatibility: 'alibaba-token-plan', + }, + }); + assert.equal(alibaba.responsesProviderOptionsKey, 'alibaba-token-plan-cn'); + assert.equal(alibaba.responsesReplayProfile, 'alibaba-token-plan-cn'); + const accountScopedAlibaba = resolveModelRuntime( + { providerType: 'alibaba-token-plan-cn', slug: 'token-plan-account-a' }, + 'qwen3.8-max', + ); + assert.equal(accountScopedAlibaba.responsesProviderOptionsKey, 'alibaba-token-plan-cn'); + assert.equal(accountScopedAlibaba.responsesReplayProfile, 'token-plan-account-a'); const xai = resolveModelRuntime({ providerType: 'xai' }, 'grok-4.5'); assert.deepEqual(xai.reasoningReplay, { @@ -195,9 +227,19 @@ describe('responses wire contract', () => { ); }); + test('Core Runtime-profile references exactly match Runtime implementations', () => { + const referenced = Object.values(PROVIDER_REGISTRY).flatMap((definition) => { + const adapter = definition.runtimeAdapter; + return adapter.kind === 'openai-compatible' && adapter.runtimeProfile + ? [adapter.runtimeProfile] + : []; + }); + assert.deepEqual([...new Set(referenced)].sort(), [...RUNTIME_PROVIDER_PROFILE_IDS].sort()); + }); + test('enables Responses only through an explicit supported contract', () => { const configured = Object.entries(PROVIDER_REGISTRY).flatMap(([providerType, definition]) => { - const adapter = definition.runtimeAdapter; + const adapter = resolveRuntimeProviderAdapter(definition.runtimeAdapter); return adapter.kind === 'openai-compatible' && adapter.responses ? [{ providerType, contract: adapter.responses }] : []; @@ -216,6 +258,22 @@ describe('responses wire contract', () => { providerType: 'xai-oauth', contract: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, }, + { + providerType: 'alibaba-token-plan-cn', + contract: { + adapter: 'open-responses', + reasoningReplay: 'plaintext-summary', + compatibility: 'alibaba-token-plan', + }, + }, + { + providerType: 'alibaba-token-plan', + contract: { + adapter: 'open-responses', + reasoningReplay: 'plaintext-summary', + compatibility: 'alibaba-token-plan', + }, + }, ]); const relay = PROVIDER_REGISTRY['openai-responses-compatible'].runtimeAdapter; @@ -246,7 +304,7 @@ describe('responses wire contract', () => { if ( runtime.wire !== 'openai-responses' || (runtime.reasoningReplay.kind === 'responses' && - runtime.reasoningReplay.contract.reasoningReplay === 'plaintext-content') + runtime.reasoningReplay.contract.adapter === 'open-responses') ) { continue; } @@ -270,6 +328,209 @@ describe('responses wire contract', () => { }); describe('responses wire request body', () => { + test('Alibaba compatibility owns store:false after request overlays', async () => { + const bodies: Array> = []; + const urls: string[] = []; + const fetch = (async (url: string | URL | Request, init?: RequestInit) => { + urls.push(String(url)); + bodies.push(JSON.parse(String(init?.body))); + return Response.json({ + id: 'response-1', + object: 'response', + created_at: 1, + model: 'qwen3.8-max', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as unknown as typeof globalThis.fetch; + const connection = { + ...conn('alibaba-token-plan-cn'), + baseUrl: 'https://token-plan.example/compatible-mode/v1', + requestBodyOverlay: { store: true }, + }; + const model = getAIModel({ + connection, + apiKey: 'token-plan-key', + modelId: 'qwen3.8-max', + fetch, + }); + + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }], + providerOptions: buildProviderOptions(connection, 'qwen3.8-max', 'medium'), + }); + + assert.deepEqual(urls, ['https://token-plan.example/compatible-mode/v1/responses']); + assert.equal(bodies[0]?.store, false); + assert.deepEqual(bodies[0]?.reasoning, { effort: 'medium' }); + }); + + test('Alibaba compatibility preserves documented required tool choices', async () => { + const bodies: Array> = []; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body))); + return Response.json({ + id: 'response-required-tool', + object: 'response', + created_at: 1, + model: 'qwen3.8-max', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as unknown as typeof globalThis.fetch; + const connection = { + ...conn('alibaba-token-plan-cn'), + baseUrl: 'https://token-plan.example/compatible-mode/v1', + }; + const model = getAIModel({ + connection, + apiKey: 'token-plan-key', + modelId: 'qwen3.8-max', + fetch, + }); + + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'look it up' }] }], + tools: [ + { + type: 'function', + name: 'lookup', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + ], + toolChoice: { type: 'required' }, + }); + + const allowedToolsChoice = { + type: 'allowed_tools', + mode: 'required', + tools: [{ type: 'function', name: 'lookup' }], + }; + const overlayModel = getAIModel({ + connection: { ...connection, requestBodyOverlay: { tool_choice: allowedToolsChoice } }, + apiKey: 'token-plan-key', + modelId: 'qwen3.8-max', + fetch, + }); + await overlayModel.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'look it up again' }] }], + tools: [ + { + type: 'function', + name: 'lookup', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + ], + }); + + assert.equal(bodies[0]?.tool_choice, 'required'); + assert.equal((bodies[0]?.tools as unknown[] | undefined)?.length, 1); + assert.equal(bodies[0]?.store, false); + assert.deepEqual(bodies[1]?.tool_choice, allowedToolsChoice); + assert.equal((bodies[1]?.tools as unknown[] | undefined)?.length, 1); + assert.equal(bodies[1]?.store, false); + }); + + test('Alibaba compatibility survives header-only request customization', async () => { + let body: Record | undefined; + let headers: Headers | undefined; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + headers = new Headers(init?.headers); + return Response.json({ + id: 'response-header-customization', + object: 'response', + created_at: 1, + model: 'qwen3.8-max', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as unknown as typeof globalThis.fetch; + const connection = { + ...conn('alibaba-token-plan-cn'), + baseUrl: 'https://token-plan.example/compatible-mode/v1', + }; + const model = getAIModel({ + connection, + apiKey: 'token-plan-key', + modelId: 'qwen3.8-max', + requestHeaders: { 'x-token-plan-routing': 'custom' }, + fetch, + }); + + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }], + }); + + assert.equal(body?.store, false); + assert.equal(headers?.get('x-token-plan-routing'), 'custom'); + }); + + test('Alibaba non-stored request carries the reconstructed summary item', async () => { + let body: Record | undefined; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + return Response.json({ + id: 'response-2', + object: 'response', + created_at: 2, + model: 'qwen3.8-max', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as unknown as typeof globalThis.fetch; + const connection = { + ...conn('alibaba-token-plan-cn'), + baseUrl: 'https://token-plan.example/compatible-mode/v1', + }; + const model = getAIModel({ + connection, + apiKey: 'token-plan-key', + modelId: 'qwen3.8-max', + fetch, + }); + + await model.doGenerate({ + prompt: [ + { role: 'user', content: [{ type: 'text', text: 'first question' }] }, + { + role: 'assistant', + content: [ + { + type: 'reasoning', + text: 'durable reasoning summary', + providerOptions: { + 'alibaba-token-plan-cn': { + itemId: 'reasoning-item-1', + reasoningSummary: [{ type: 'summary_text', text: 'durable reasoning summary' }], + reasoningContent: null, + }, + }, + }, + { type: 'text', text: 'first answer' }, + ], + }, + { role: 'user', content: [{ type: 'text', text: 'follow up' }] }, + ], + }); + + assert.equal(body?.store, false); + assert.deepEqual( + (body?.input as Array> | undefined)?.find( + (item) => item.type === 'reasoning', + ), + { + type: 'reasoning', + id: 'reasoning-item-1', + summary: [{ type: 'summary_text', text: 'durable reasoning summary' }], + }, + ); + }); + test('adds the V2 trigger only through the explicit OpenAI provider option', async () => { const bodies: Array> = []; const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 48bb09af68..d8032d6159 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -172,6 +172,11 @@ import { type RequestProjectionContext, type RequestProjectionStage, } from './request-projection.js'; +import { + decodePlaintextResponsesReasoningState, + replayPlaintextResponsesProviderOptions, + responsesReasoningItemId, +} from './responses-reasoning-state.js'; import type { ActiveToolResultPruneDiagnosticPatch } from './active-tool-result-prune.js'; import { toolResultOutput } from './tool-result-output.js'; import { compactionDecisionDiagnosticPatch } from './compaction-boundary.js'; @@ -1456,6 +1461,7 @@ export class AiSdkBackend implements AgentBackend { let sawStepThinking = false; let stepThinkingProviderOptions: NonNullable | undefined; let stepResponsesThinkingParts: AssistantThinkingPart[] = []; + let stepResponsesThinkingPartsByItemId = new Map(); let stepSignature: string | undefined; const startedAt = this.now(); @@ -1551,6 +1557,7 @@ export class AiSdkBackend implements AgentBackend { sawStepThinking = false; stepThinkingProviderOptions = undefined; stepResponsesThinkingParts = []; + stepResponsesThinkingPartsByItemId = new Map(); stepSignature = undefined; }; let tokenUsage: NormalizedAiSdkUsage | undefined; @@ -2301,32 +2308,58 @@ export class AiSdkBackend implements AgentBackend { } stepThinkingProviderOptions = event.providerOptions; } - const openai = event.providerOptions?.openai; const itemId = - openai && typeof openai === 'object' && !Array.isArray(openai) - ? (openai as { itemId?: unknown }).itemId - : undefined; + event.reasoningItemId ?? responsesReasoningItemId(event.providerOptions); if (typeof itemId === 'string' && itemId.length > 0) { - let part = stepResponsesThinkingParts.find( - (candidate) => - (candidate.providerOptions?.openai as { itemId?: unknown } | undefined) - ?.itemId === itemId, - ); + let part = stepResponsesThinkingPartsByItemId.get(itemId); + if ( + part && + event.providerOptions === undefined && + decodePlaintextResponsesReasoningState(part.providerOptions).kind === 'valid' + ) { + // The SDK does not suppress a stray delta after + // output_item.done. Keep it out of the finalized item or + // its durable summary boundaries will no longer match. + part = { text: '' }; + stepResponsesThinkingParts.push(part); + stepResponsesThinkingPartsByItemId.set(itemId, part); + } if (!part) { part = { text: stepResponsesThinkingParts.length === 0 && event.text.length === 0 ? stepThinking : '', - providerOptions: event.providerOptions, }; stepResponsesThinkingParts.push(part); - } else { + stepResponsesThinkingPartsByItemId.set(itemId, part); + } + const nextPartText = part.text + event.text; + if ( + event.reasoningSummaryText !== undefined && + event.reasoningSummaryText !== nextPartText + ) { + throw new Error( + 'Streamed plaintext Responses reasoning does not match final provider summary', + ); + } + part.text = nextPartText; + if (event.providerOptions !== undefined) { part.providerOptions = event.providerOptions; } - part.text += event.text; } else if (stepResponsesThinkingParts.length > 0) { - stepResponsesThinkingParts.at(-1)!.text += event.text; + const lastPart = stepResponsesThinkingParts.at(-1)!; + const lastState = decodePlaintextResponsesReasoningState( + lastPart.providerOptions, + ); + if (lastState.kind === 'valid') { + // An invalid next item has no usable stream id. Do not + // append its deltas to the finalized item: partial-error + // flush must keep that item's durable boundaries valid. + stepResponsesThinkingParts.push({ text: event.text }); + } else { + lastPart.text += event.text; + } } queue.push({ type: 'thinking_delta', @@ -3751,15 +3784,42 @@ export class AiSdkBackend implements AgentBackend { } : undefined; } - if (replaySupport.responsesReasoning === 'plaintext-content') { - if (item.text.length === 0) return undefined; + if ( + typeof replaySupport.responsesReasoning === 'object' && + replaySupport.responsesReasoning.kind === 'plaintext-item' + ) { + const decoded = decodePlaintextResponsesReasoningState(item.providerOptions); + if (decoded.kind === 'missing') return undefined; + if (decoded.kind === 'unsupported-version') return undefined; + if (decoded.kind === 'malformed') { + if ( + decoded.profile !== undefined && + decoded.profile !== replaySupport.responsesReasoning.profile + ) { + return undefined; + } + throw new Error('Malformed durable plaintext Responses reasoning state'); + } + const state = decoded.state; + if (state.profile !== replaySupport.responsesReasoning.profile) { + return undefined; + } return { part: { type: 'reasoning' as const, text: item.text, + providerOptions: replayPlaintextResponsesProviderOptions({ + providerOptionsKey: replaySupport.responsesReasoning.providerOptionsKey, + state, + text: item.text, + }), }, }; } + if (replaySupport.responsesReasoning === 'plaintext-content') { + if (item.text.length === 0) return undefined; + return { part: { type: 'reasoning' as const, text: item.text } }; + } if (replaySupport.responsesReasoning === 'encrypted-content') { const openai = item.providerOptions?.openai; if (openai && typeof openai === 'object' && !Array.isArray(openai)) { diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index a21f89f3e1..b080ea4947 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -56,6 +56,10 @@ export type { } from './model-protocol.js'; import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime.js'; +import { + plaintextResponsesReasoningProviderOptions, + safePlaintextResponsesReasoningItemId, +} from './responses-reasoning-state.js'; import { classifyError, errorPresentationFromClass, @@ -186,9 +190,17 @@ export class ModelAdapter { // recorded to the event log and rendered regardless. unsignedThinking: this.runtime.reasoningReplay.kind === 'openai-chat-plaintext', responsesReasoning: - this.runtime.reasoningReplay.kind === 'responses' - ? this.runtime.reasoningReplay.contract.reasoningReplay - : 'none', + this.runtime.reasoningReplay.kind !== 'responses' + ? 'none' + : this.runtime.reasoningReplay.contract.reasoningReplay === 'encrypted-content' + ? 'encrypted-content' + : this.runtime.reasoningReplay.contract.reasoningReplay === 'plaintext-content' + ? 'plaintext-content' + : { + kind: 'plaintext-item', + profile: requireResponsesReplayProfile(this.runtime), + providerOptionsKey: requireResponsesProviderOptionsKey(this.runtime), + }, }; } @@ -327,6 +339,7 @@ export class ModelAdapter { ? this.openAiChatReasoningTransportState : undefined; const openAiResponsesTransportState = this.openAiResponsesTransportState; + const resolvedRuntime = this.runtime; let settleOutcome!: (outcome: ModelStepOutcome) => void; const outcome = new Promise((resolve) => { settleOutcome = resolve; @@ -337,10 +350,33 @@ export class ModelAdapter { let failure: ModelFailure | undefined; let sawFinish = false; let streamedFinishReason: string | undefined; + let streamedRawFinishReason: string | undefined; + let sawUnfinalizedPlaintextSummary = false; try { for await (const chunk of sdk.stream as AsyncIterable) { onStreamActivity(); - for (const event of translateChunk(chunk, openAiChatReasoningTransportState)) { + if ( + chunk.type === 'finish' || + chunk.type === 'finish-step' || + chunk.type === 'step-finish' + ) { + streamedRawFinishReason = + rawFinishReasonString(chunk.rawFinishReason) ?? streamedRawFinishReason; + } + if (isUnfinalizedPlaintextSummaryReasoningEnd(chunk, resolvedRuntime)) { + // The SDK emits this trailer from flush() when no + // response.output_item.done finalized the active item. Defer the + // decision until the terminal outcome is known: an existing + // provider failure must win, while a successful stream must + // still fail closed instead of losing replay state silently. + sawUnfinalizedPlaintextSummary = true; + continue; + } + for (const event of translateChunk( + chunk, + openAiChatReasoningTransportState, + resolvedRuntime, + )) { if (event.kind === 'error') failure = event.failure; if (event.kind === 'finish') sawFinish = true; if (event.kind === 'finish' || event.kind === 'step-finish') { @@ -350,24 +386,56 @@ export class ModelAdapter { } } } catch (error) { - failure = normalizeProviderFailure(error); - yield { kind: 'error', failure }; + if (!failure) { + failure = normalizeProviderFailure(error); + yield { kind: 'error', failure }; + } } finally { const [sdkUsage, sdkFinishReason] = await Promise.all([ sdk.usage.catch(() => undefined), sdk.finishReason.catch(() => undefined), ]); + // An early-stopping consumer (a provider-mismatch throw, a user + // stop) ends this stream before any finish chunk exists, so the SDK + // rejects every result promise during teardown. `usage` and + // `finishReason` are consumed above; `response` is only read on the + // completed continuation path below. Sink it unconditionally so the + // error path can never surface an unhandled rejection after the + // turn unwinds — the timing of that settlement is scheduler-owned + // (observed post-test on Windows), and Node's default makes an + // unhandled rejection a crash. + void Promise.resolve(sdk.response).catch(() => undefined); const finishReason = streamedFinishReason ?? rawFinishReasonString(sdkFinishReason) ?? 'unknown'; - const usage = normalizeAiSdkUsage(sdkUsage, { rawFinishReason: finishReason }); + const rawFinishReason = + streamedRawFinishReason ?? rawFinishReasonString(sdkFinishReason) ?? finishReason; + const usage = normalizeAiSdkUsage(sdkUsage, { rawFinishReason }); let settled = settleModelStepOutcome({ aborted: continuation.abortSignal.aborted, failure, sawFinish, finishReason, + rawFinishReason, usage, request, }); + let deferredFailure: ModelFailure | undefined; + + if (sawUnfinalizedPlaintextSummary && settled.kind === 'completed') { + failure = normalizeProviderFailure( + new Error('Plaintext Responses reasoning item is missing final summary metadata'), + ); + deferredFailure = failure; + settled = settleModelStepOutcome({ + aborted: continuation.abortSignal.aborted, + failure, + sawFinish, + finishReason, + rawFinishReason, + usage, + request, + }); + } try { if (continuation.lane) { @@ -392,6 +460,12 @@ export class ModelAdapter { } finally { settleOutcome(settled); } + if (deferredFailure) { + // Consumers may stop iterating at the first error. The outcome and + // continuation lane must already be settled before this yield so a + // generator return cannot strand the caller awaiting result.outcome. + yield { kind: 'error', failure: deferredFailure }; + } } }, }; @@ -427,6 +501,7 @@ export class ModelAdapter { this.runtime.reasoningReplay.kind === 'openai-chat-plaintext' ? this.openAiChatReasoningTransportState : undefined, + this.runtime, ); } @@ -473,12 +548,13 @@ interface ModelStepSettlementEvidence { failure?: ModelFailure; sawFinish: boolean; finishReason: ModelFinishReason; + rawFinishReason?: string; usage?: NormalizedUsage; request: ModelRequestMetadata; } export function settleModelStepOutcome(evidence: ModelStepSettlementEvidence): ModelStepOutcome { - const { aborted, failure, sawFinish, finishReason, usage, request } = evidence; + const { aborted, failure, sawFinish, finishReason, rawFinishReason, usage, request } = evidence; if (aborted || failure?.kind === 'abort') { return failedStepOutcome( 'aborted', @@ -507,14 +583,13 @@ export function settleModelStepOutcome(evidence: ModelStepSettlementEvidence): M ); } if (finishReason === 'content-filter' || finishReason === 'error') { + const terminalFailure = + finishReason === 'error' + ? providerFinishFailure(rawFinishReason) + : modelStepFailure('unknown', 'Provider stopped the stream on a content filter'); return failedStepOutcome( - 'terminal-failure', - modelStepFailure( - finishReason === 'content-filter' ? 'unknown' : 'provider_unavailable', - finishReason === 'content-filter' - ? 'Provider stopped the stream on a content filter' - : 'Provider stopped the stream with an error', - ), + terminalFailure.retryable ? 'retryable-failure' : 'terminal-failure', + terminalFailure, request, usage, ); @@ -532,6 +607,24 @@ function modelStepFailure(kind: ModelFailureKind, message: string): ModelFailure return { type: 'model_failure', kind, message, retryable: false }; } +function providerFinishFailure(rawFinishReason: string | undefined): ModelFailure { + if (rawFinishReason && rawFinishReason !== 'error') { + const normalized = normalizeProviderFailure({ + code: rawFinishReason, + message: 'Provider stopped the stream with an error', + }); + // A finish reason carries no request-level Retry-After or transport + // evidence. Preserve its classification for diagnostics without widening + // the pre-existing retry policy for every provider. + if (normalized.kind !== 'unknown') return { ...normalized, retryable: false }; + return { + ...modelStepFailure('provider_unavailable', 'Provider stopped the stream with an error'), + ...(normalized.code ? { code: normalized.code } : {}), + }; + } + return modelStepFailure('provider_unavailable', 'Provider stopped the stream with an error'); +} + function failedStepOutcome( kind: Exclude, failure: ModelFailure, @@ -590,7 +683,29 @@ export interface ModelAdapterRuntimeEventReplaySupport { providerExecutedTools: boolean; signedThinking: boolean; unsignedThinking: boolean; - responsesReasoning: 'none' | 'encrypted-content' | 'plaintext-content'; + responsesReasoning: + | 'none' + | 'encrypted-content' + | 'plaintext-content' + | { + kind: 'plaintext-item'; + profile: string; + providerOptionsKey: string; + }; +} + +function requireResponsesProviderOptionsKey(runtime: ResolvedModelRuntime): string { + if (!runtime.responsesProviderOptionsKey) { + throw new Error('Plaintext Responses replay requires a provider-options key'); + } + return runtime.responsesProviderOptionsKey; +} + +function requireResponsesReplayProfile(runtime: ResolvedModelRuntime): string { + if (!runtime.responsesReplayProfile) { + throw new Error('Plaintext Responses replay requires a source profile'); + } + return runtime.responsesReplayProfile; } /** @@ -669,8 +784,48 @@ function reasoningSignatureFromChunk(chunk: AiSdkStreamChunk): string | undefine function openAiResponsesReasoningProviderOptionsFromChunk( chunk: AiSdkStreamChunk, + runtime: ResolvedModelRuntime, ): NonNullable | undefined { const meta = chunk.providerMetadata; + if ( + runtime.reasoningReplay.kind === 'responses' && + runtime.reasoningReplay.contract.reasoningReplay === 'plaintext-summary' + ) { + if (chunk.type !== 'reasoning' && chunk.type !== 'reasoning-end') return undefined; + const providerOptionsKey = runtime.responsesProviderOptionsKey; + const provider = + providerOptionsKey && meta && typeof meta === 'object' + ? (meta as Record)[providerOptionsKey] + : undefined; + const metadataItemIdValue = + provider && typeof provider === 'object' && !Array.isArray(provider) + ? (provider as { itemId?: unknown }).itemId + : undefined; + const streamItemIdValue = (chunk as { id?: unknown }).id; + if ( + typeof metadataItemIdValue === 'string' && + typeof streamItemIdValue === 'string' && + metadataItemIdValue !== streamItemIdValue + ) { + throw new Error('Plaintext Responses reasoning item id changed within one stream item'); + } + const itemId = + safePlaintextResponsesReasoningItemId(metadataItemIdValue) ?? + safePlaintextResponsesReasoningItemId(streamItemIdValue); + const summaryParts = plaintextSummaryParts(provider); + if (!itemId || !summaryParts) { + throw new Error('Plaintext Responses reasoning item is missing final summary metadata'); + } + const providerOptions = plaintextResponsesReasoningProviderOptions( + itemId, + requireResponsesReplayProfile(runtime), + summaryParts, + ); + if (!providerOptions) { + throw new Error('Plaintext Responses reasoning summary exceeds durable state bounds'); + } + return providerOptions; + } if (!meta || typeof meta !== 'object') return undefined; const openai = (meta as { openai?: unknown }).openai; if (!openai || typeof openai !== 'object' || Array.isArray(openai)) return undefined; @@ -689,6 +844,65 @@ function openAiResponsesReasoningProviderOptionsFromChunk( }; } +function isUnfinalizedPlaintextSummaryReasoningEnd( + chunk: AiSdkStreamChunk, + runtime: ResolvedModelRuntime, +): boolean { + return ( + runtime.reasoningReplay.kind === 'responses' && + runtime.reasoningReplay.contract.reasoningReplay === 'plaintext-summary' && + chunk.type === 'reasoning-end' && + (chunk.providerMetadata === undefined || chunk.providerMetadata === null) + ); +} + +function plaintextSummaryParts(provider: unknown): string[] | undefined { + if (!provider || typeof provider !== 'object' || Array.isArray(provider)) return undefined; + const summary = (provider as { reasoningSummary?: unknown }).reasoningSummary; + if (!Array.isArray(summary)) return undefined; + const parts: string[] = []; + for (const part of summary) { + if (!part || typeof part !== 'object' || Array.isArray(part)) return undefined; + const { type, text } = part as { type?: unknown; text?: unknown }; + if (type !== 'summary_text' || typeof text !== 'string') return undefined; + parts.push(text); + } + return parts; +} + +function plaintextSummaryTextFromChunk( + chunk: AiSdkStreamChunk, + runtime: ResolvedModelRuntime | undefined, +): string | undefined { + if ( + runtime?.reasoningReplay.kind !== 'responses' || + runtime.reasoningReplay.contract.reasoningReplay !== 'plaintext-summary' || + (chunk.type !== 'reasoning' && chunk.type !== 'reasoning-end') + ) { + return undefined; + } + const providerOptionsKey = runtime.responsesProviderOptionsKey; + const meta = chunk.providerMetadata; + const provider = + providerOptionsKey && meta && typeof meta === 'object' + ? (meta as Record)[providerOptionsKey] + : undefined; + return plaintextSummaryParts(provider)?.join(''); +} + +function plaintextSummaryItemIdFromChunk( + chunk: AiSdkStreamChunk, + runtime: ResolvedModelRuntime | undefined, +): string | undefined { + if ( + runtime?.reasoningReplay.kind !== 'responses' || + runtime.reasoningReplay.contract.reasoningReplay !== 'plaintext-summary' + ) { + return undefined; + } + return safePlaintextResponsesReasoningItemId((chunk as { id?: unknown }).id); +} + /** * Translate one raw AI SDK stream chunk into zero or more Maka-owned * `ModelStreamEvent`s. The sole site that parses SDK chunk names; the backend @@ -697,8 +911,13 @@ function openAiResponsesReasoningProviderOptionsFromChunk( function translateChunk( chunk: AiSdkStreamChunk, openAiChatReasoningTransportState?: OpenAiChatReasoningTransportState, + runtime?: ResolvedModelRuntime, ): ModelStreamEvent[] { switch (chunk.type) { + case 'reasoning-start': { + const reasoningItemId = plaintextSummaryItemIdFromChunk(chunk, runtime); + return reasoningItemId ? [{ kind: 'thinking', text: '', reasoningItemId }] : []; + } case 'text-start': return [{ kind: 'text-start' }]; case 'text-delta': { @@ -725,7 +944,11 @@ function translateChunk( ? chunk.delta : undefined; const signature = reasoningSignatureFromChunk(chunk); - const responsesProviderOptions = openAiResponsesReasoningProviderOptionsFromChunk(chunk); + const responsesProviderOptions = runtime + ? openAiResponsesReasoningProviderOptionsFromChunk(chunk, runtime) + : undefined; + const reasoningItemId = plaintextSummaryItemIdFromChunk(chunk, runtime); + const reasoningSummaryText = plaintextSummaryTextFromChunk(chunk, runtime); const events: ModelStreamEvent[] = []; if (signature) events.push({ kind: 'thinking-signature', signature }); // The signed reasoning chunk arrives as a standalone delta with empty @@ -745,17 +968,31 @@ function translateChunk( providerOptionsOrigin: 'maka_transport' as const, } : {}), + ...(reasoningItemId ? { reasoningItemId } : {}), + ...(reasoningSummaryText !== undefined ? { reasoningSummaryText } : {}), }); } return events; } case 'reasoning-end': { const signature = reasoningSignatureFromChunk(chunk); - const responsesProviderOptions = openAiResponsesReasoningProviderOptionsFromChunk(chunk); + const responsesProviderOptions = runtime + ? openAiResponsesReasoningProviderOptionsFromChunk(chunk, runtime) + : undefined; + const reasoningItemId = plaintextSummaryItemIdFromChunk(chunk, runtime); + const reasoningSummaryText = plaintextSummaryTextFromChunk(chunk, runtime); return [ ...(signature ? [{ kind: 'thinking-signature' as const, signature }] : []), ...(responsesProviderOptions - ? [{ kind: 'thinking' as const, text: '', providerOptions: responsesProviderOptions }] + ? [ + { + kind: 'thinking' as const, + text: '', + providerOptions: responsesProviderOptions, + ...(reasoningItemId ? { reasoningItemId } : {}), + ...(reasoningSummaryText !== undefined ? { reasoningSummaryText } : {}), + }, + ] : []), ]; } @@ -771,9 +1008,12 @@ function translateChunk( case 'finish-step': case 'step-finish': { const finishReason = chunkFinishReason(chunk); + const rawFinishReason = rawFinishReasonString(chunk.rawFinishReason); // The same value the turn's outcome is decided from, so the record and // the outcome cannot name different reasons for the same stream. - const usage = normalizeAiSdkUsage(chunk.usage, { rawFinishReason: finishReason }); + const usage = normalizeAiSdkUsage(chunk.usage, { + rawFinishReason: rawFinishReason ?? finishReason, + }); return [ { kind: 'step-finish', @@ -786,7 +1026,6 @@ function translateChunk( const finishReason = chunkFinishReason(chunk); return [{ kind: 'finish', ...(finishReason ? { finishReason } : {}) }]; } - case 'reasoning-start': case 'start-step': case 'tool-result': case 'tool-error': { diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 37dd1216be..da6b77b27e 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -32,7 +32,6 @@ import { type SharedV4ProviderOptions, } from '@ai-sdk/provider'; import { type RuntimeExecutionConnection } from '@maka/core/llm-connections'; -import type { ProviderRuntimeAdapter } from '@maka/core/llm-connections'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import { resolveThinkingLevel, @@ -53,7 +52,9 @@ import { openAiResponsesBaseUrl, openResponsesUrl, } from './provider-urls.js'; +import { createOpenResponsesCompatibilityFinalizer } from './open-responses-compatibility.js'; import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime.js'; +import { runtimeProviderName, type RuntimeProviderAdapter } from './provider-runtime-policy.js'; import { openAiCodexHeaders } from './subscription-auth.js'; import { createRequestCustomizationFetch } from './request-customization-fetch.js'; @@ -85,10 +86,12 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { const hasRequestCustomization = Object.keys(requestHeaders ?? {}).length > 0 || Object.keys(connection.requestBodyOverlay ?? {}).length > 0; - const requestFetch = createRequestCustomizationFetch(fetch ?? globalThis.fetch, { + const baseFetch = fetch ?? globalThis.fetch; + const requestCustomization = { headers: requestHeaders, bodyOverlay: connection.requestBodyOverlay, - }); + } as const; + const requestFetch = createRequestCustomizationFetch(baseFetch, requestCustomization); if (adapter.kind === 'google' && adapter.normalizeBaseUrl === false) { return createGoogle({ apiKey, baseURL, fetch: requestFetch }).chat(modelId); @@ -174,11 +177,20 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { throw new Error('Responses wire requires a Responses continuation contract'); } if (reasoningReplay.contract.adapter === 'open-responses') { + // Request customization is applied first; provider compatibility is + // the final authority before network dispatch, so an overlay cannot + // re-enable storage or violate the provider's tool-choice contract. + const responsesFetch = createRequestCustomizationFetch(baseFetch, { + ...requestCustomization, + finalizeBody: createOpenResponsesCompatibilityFinalizer( + reasoningReplay.contract.compatibility, + ), + }); return createOpenResponses({ - name: openAiCompatibleProviderName(adapter, connection), + name: runtimeProviderName(adapter, connection), apiKey, url: openResponsesUrl(baseURL), - fetch: requestFetch, + fetch: responsesFetch, })(modelId); } return createOpenAI({ @@ -205,7 +217,7 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { ) : reasoningTransport.transformRequestBody; const model = createOpenAICompatible({ - name: openAiCompatibleProviderName(adapter, connection), + name: runtimeProviderName(adapter, connection), apiKey, baseURL, includeUsage: adapter.includeUsage, @@ -622,7 +634,7 @@ function buildFamilyWire( // openai-compatible — so key by the same name getAIModel passes. return reasoningEffort || serviceTier ? { - [openAiCompatibleProviderName(adapter, connection)]: { + [runtimeProviderName(adapter, connection)]: { ...(reasoningEffort ? { reasoningEffort } : {}), ...(serviceTier ? { serviceTier } : {}), }, @@ -684,20 +696,6 @@ function buildFamilyWire( } } -/** - * The provider IDENTITY passed as `name` to `createOpenAICompatible` in - * `getAIModel` — the raw slug for custom relays. Distinct from the - * providerOptions key the SDK wants: see `openAiCompatibleProviderOptionsKey`. - */ -function openAiCompatibleProviderName( - adapter: ProviderRuntimeAdapter, - connection: RuntimeExecutionConnection, -): string { - return adapter.kind === 'openai-compatible' && adapter.name === 'connection' - ? connection.slug - : connection.providerType; -} - // Mirrors @ai-sdk/openai-compatible's own toCamelCase derivation, so the // key we emit always matches the alias the SDK resolves. function toCamelCase(name: string): string { @@ -718,8 +716,8 @@ function toCamelCase(name: string): string { * silently read nothing for dashed providers. */ function openAiCompatibleProviderOptionsKey( - adapter: ProviderRuntimeAdapter, + adapter: RuntimeProviderAdapter, connection: RuntimeExecutionConnection, ): string { - return toCamelCase(openAiCompatibleProviderName(adapter, connection)); + return toCamelCase(runtimeProviderName(adapter, connection)); } diff --git a/packages/runtime/src/model-protocol.ts b/packages/runtime/src/model-protocol.ts index fe1a1db769..be9c0d0c8c 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -393,6 +393,10 @@ export type ModelStreamEvent = kind: 'thinking'; text: string; providerOptions?: ProviderOptions; + /** Bounded item identity used only while grouping one streamed reasoning item. */ + reasoningItemId?: string; + /** Final provider summary, compared before only its part boundaries are persisted. */ + reasoningSummaryText?: string; /** Maka-authored replay hint; absent provider metadata stays fail-closed. */ providerOptionsOrigin?: 'maka_transport'; } diff --git a/packages/runtime/src/model-runtime.ts b/packages/runtime/src/model-runtime.ts index 874c3e206b..bd8d249f13 100644 --- a/packages/runtime/src/model-runtime.ts +++ b/packages/runtime/src/model-runtime.ts @@ -21,7 +21,6 @@ import { PROVIDER_DEFAULTS, effectiveBaseUrl, type ModelInfo, - type ProviderResponsesContract, type ProviderRuntimeAdapter, type ProviderType, } from '@maka/core/llm-connections'; @@ -32,6 +31,12 @@ import { } from '@maka/core/model-metadata'; import { isRetiredProvider } from '@maka/core/provider-registry'; import { resolveApplyPatchProfile, type ApplyPatchProfile } from './apply-patch-profile.js'; +import { + resolveRuntimeProviderAdapter, + runtimeProviderName, + type RuntimeProviderAdapter, + type RuntimeProviderResponsesContract, +} from './provider-runtime-policy.js'; export type ModelRuntimeWire = | 'anthropic-messages' @@ -44,10 +49,10 @@ export type ReasoningReplayContract = | { kind: 'none' } | { kind: 'anthropic-signed' } | { kind: 'openai-chat-plaintext'; requestField: 'observed' | 'reasoning' } - | { kind: 'responses'; contract: ProviderResponsesContract }; + | { kind: 'responses'; contract: RuntimeProviderResponsesContract }; export interface ResolvedModelRuntime { - adapter: ProviderRuntimeAdapter; + adapter: RuntimeProviderAdapter; baseUrl: string; /** Account-advertised request wire for adapters that route per model. */ apiProtocol?: ModelInfo['apiProtocol']; @@ -57,11 +62,16 @@ export interface ResolvedModelRuntime { reasoningReplay: ReasoningReplayContract; /** Effective parallel-tool-call support after model facts and wire defaults are resolved. */ parallelToolCalls?: boolean; + /** Provider-options namespace used by durable plaintext-summary replay. */ + responsesProviderOptionsKey?: string; + /** Stable connection identity that issued a durable plaintext-summary item. */ + responsesReplayProfile?: string; /** Effective ApplyPatch contract after provider, model, and request wire are resolved. */ applyPatchProfile: ApplyPatchProfile | null; } export interface ModelRuntimeConnection { + readonly slug?: string; readonly providerType: ProviderType; readonly baseUrl?: string; readonly models?: readonly ModelInfo[]; @@ -102,7 +112,7 @@ export function resolveModelRuntime( `Kimi Coding Plan protocol must be openai-chat or anthropic-messages, received ${apiProtocol}`, ); } - const adapter: ProviderRuntimeAdapter = + const baseAdapter: ProviderRuntimeAdapter = connection.providerType === 'kimi-coding-plan' && apiProtocol === 'openai-chat' ? ({ kind: 'openai-compatible', @@ -112,12 +122,14 @@ export function resolveModelRuntime( : override ? runtimeAdapterOverride(override.npm) : defaults.runtimeAdapter; + const adapter = resolveRuntimeProviderAdapter(baseAdapter); const configuredBaseUrl = connection.baseUrl?.trim(); const resolvedBaseUrl = configuredBaseUrl ? effectiveBaseUrl(connection) : (override?.api ?? effectiveBaseUrl(connection)); const wire = resolveModelRuntimeWire(connection.providerType, modelId, adapter, apiProtocol); const parallelToolCalls = resolveParallelToolCalls(connection, modelId, adapter); + const replay = reasoningReplayContract(adapter, wire); return { adapter, baseUrl: @@ -126,8 +138,16 @@ export function resolveModelRuntime( : resolvedBaseUrl, ...(apiProtocol ? { apiProtocol } : {}), wire, - reasoningReplay: reasoningReplayContract(adapter, wire), + reasoningReplay: replay, ...(parallelToolCalls === undefined ? {} : { parallelToolCalls }), + ...(replay.kind === 'responses' && + replay.contract.adapter === 'open-responses' && + replay.contract.reasoningReplay === 'plaintext-summary' + ? { + responsesProviderOptionsKey: runtimeProviderName(adapter, connection), + responsesReplayProfile: connection.slug ?? connection.providerType, + } + : {}), applyPatchProfile: resolveApplyPatchProfile( { wire, @@ -141,7 +161,7 @@ export function resolveModelRuntime( function resolveParallelToolCalls( connection: ModelRuntimeConnection, modelId: string, - adapter: ProviderRuntimeAdapter, + adapter: RuntimeProviderAdapter, ): boolean | undefined { const stored = connection.models?.find((model) => model.id === modelId)?.capabilities ?.parallelToolCalls; @@ -177,7 +197,7 @@ export function modelUsesNativeOpenAiResponses( function resolveModelRuntimeWire( providerType: ProviderType, modelId: string, - adapter: ProviderRuntimeAdapter, + adapter: RuntimeProviderAdapter, apiProtocol: ModelInfo['apiProtocol'] | undefined, ): ModelRuntimeWire { switch (adapter.kind) { @@ -215,7 +235,7 @@ function resolveModelRuntimeWire( } function reasoningReplayContract( - adapter: ProviderRuntimeAdapter, + adapter: RuntimeProviderAdapter, wire: ModelRuntimeWire, ): ReasoningReplayContract { switch (wire) { @@ -237,7 +257,7 @@ function reasoningReplayContract( } } -function responsesContract(adapter: ProviderRuntimeAdapter): ProviderResponsesContract { +function responsesContract(adapter: RuntimeProviderAdapter): RuntimeProviderResponsesContract { if (adapter.kind === 'openai-compatible' && adapter.responses) return adapter.responses; return { adapter: 'openai', reasoningReplay: 'encrypted-content' }; } diff --git a/packages/runtime/src/open-responses-compatibility.ts b/packages/runtime/src/open-responses-compatibility.ts new file mode 100644 index 0000000000..2081efe82d --- /dev/null +++ b/packages/runtime/src/open-responses-compatibility.ts @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { OpenResponsesCompatibilityProfile } from './provider-runtime-policy.js'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requiredToolCount(body: Record): number | undefined { + const choice = body.tool_choice; + if (choice === 'required') { + return Array.isArray(body.tools) ? body.tools.length : 0; + } + if (isRecord(choice) && choice.type === 'allowed_tools' && choice.mode === 'required') { + return Array.isArray(choice.tools) ? choice.tools.length : 0; + } + return undefined; +} + +export function createOpenResponsesCompatibilityFinalizer( + profile: OpenResponsesCompatibilityProfile | undefined, +): ((body: Record) => Record) | undefined { + if (!profile) return undefined; + return (body) => { + const choice = body.tool_choice; + const forcedToolCount = requiredToolCount(body); + if (forcedToolCount !== undefined && forcedToolCount !== 1) { + throw new Error('Alibaba Token Plan Responses requires exactly one tool for tool_choice'); + } + if (isRecord(choice) && choice.type !== 'allowed_tools') { + throw new Error('Alibaba Token Plan Responses does not support this tool_choice object'); + } + return { ...body, store: false }; + }; +} diff --git a/packages/runtime/src/provider-runtime-policy.ts b/packages/runtime/src/provider-runtime-policy.ts new file mode 100644 index 0000000000..6a24c64340 --- /dev/null +++ b/packages/runtime/src/provider-runtime-policy.ts @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + ProviderResponsesContract, + ProviderRuntimeAdapter, + ProviderRuntimeProfileId, + ProviderType, +} from '@maka/core/llm-connections'; + +export type OpenResponsesCompatibilityProfile = 'alibaba-token-plan'; + +export type RuntimeProviderResponsesContract = + | Extract + | { + readonly adapter: 'open-responses'; + readonly reasoningReplay: 'plaintext-content' | 'plaintext-summary'; + readonly compatibility?: OpenResponsesCompatibilityProfile; + }; + +type OpenAiCompatibleRuntimeAdapter = Omit< + Extract, + 'responses' +> & { + readonly responses?: RuntimeProviderResponsesContract; +}; + +export type RuntimeProviderAdapter = + | Exclude + | OpenAiCompatibleRuntimeAdapter; + +interface RuntimeProviderIdentity { + readonly providerType: ProviderType; + readonly slug?: string; +} + +interface RuntimeProviderProfile { + readonly responses: RuntimeProviderResponsesContract; +} + +const ALIBABA_TOKEN_PLAN_RESPONSES = { + adapter: 'open-responses', + // The pinned SDK streams `response.reasoning_text.delta` as reasoning text, + // then reads durable summary boundaries from `output_item.done.item.summary`. + // Keep the raw-SSE contract test in sync if either channel changes. + reasoningReplay: 'plaintext-summary', + compatibility: 'alibaba-token-plan', +} as const satisfies RuntimeProviderResponsesContract; + +const RUNTIME_PROVIDER_PROFILES = { + 'alibaba-token-plan': { responses: ALIBABA_TOKEN_PLAN_RESPONSES }, +} as const satisfies Record; + +export const RUNTIME_PROVIDER_PROFILE_IDS = Object.freeze( + Object.keys(RUNTIME_PROVIDER_PROFILES) as ProviderRuntimeProfileId[], +); + +export function resolveRuntimeProviderAdapter( + adapter: ProviderRuntimeAdapter, +): RuntimeProviderAdapter { + if (adapter.kind !== 'openai-compatible' || adapter.runtimeProfile === undefined) { + return adapter; + } + return { ...adapter, responses: RUNTIME_PROVIDER_PROFILES[adapter.runtimeProfile].responses }; +} + +/** Raw provider identity passed to open-responses and used as its provider-options key. */ +export function runtimeProviderName( + adapter: RuntimeProviderAdapter, + connection: RuntimeProviderIdentity, +): string { + return adapter.kind === 'openai-compatible' && adapter.name === 'connection' + ? (connection.slug ?? connection.providerType) + : connection.providerType; +} diff --git a/packages/runtime/src/request-customization-fetch.ts b/packages/runtime/src/request-customization-fetch.ts index a7e8dc3de0..9fcddabdac 100644 --- a/packages/runtime/src/request-customization-fetch.ts +++ b/packages/runtime/src/request-customization-fetch.ts @@ -26,6 +26,8 @@ import { export interface RequestCustomization { readonly headers?: Readonly>; readonly bodyOverlay?: JsonObject; + /** Final provider-owned body policy, applied after caller overlays. */ + readonly finalizeBody?: (body: Record) => Record; } export function createRequestCustomizationFetch( @@ -34,7 +36,14 @@ export function createRequestCustomizationFetch( ): typeof globalThis.fetch { const headers = normalizeRequestHeaders(customization.headers ?? {}); const bodyOverlay = normalizeRequestBodyOverlay(customization.bodyOverlay ?? {}); - if (Object.keys(headers).length === 0 && Object.keys(bodyOverlay).length === 0) return upstream; + const bodyOverlayKeys = Object.keys(bodyOverlay); + if ( + Object.keys(headers).length === 0 && + bodyOverlayKeys.length === 0 && + !customization.finalizeBody + ) { + return upstream; + } return async (input, init) => { const request = new Request(input, init); @@ -48,14 +57,28 @@ export function createRequestCustomizationFetch( } let body: BodyInit | null = request.body === null ? null : await request.clone().arrayBuffer(); - if (Object.keys(bodyOverlay).length > 0 && requestHasJsonBody(request)) { - const generatedBody = await parseRequestBody(request); - for (const key of Object.keys(bodyOverlay)) { + if (bodyOverlayKeys.length > 0 || customization.finalizeBody) { + if (!requestHasJsonBody(request)) { + if (customization.finalizeBody) { + throw new Error('Request body finalizer requires a JSON object request body'); + } + return upstream(request.url, requestInit(request, nextHeaders, body)); + } + const generatedBody = await parseRequestBody( + request, + customization.finalizeBody + ? 'Request body finalizer requires a JSON object request body' + : 'Extra request body can only be applied to a JSON object request', + ); + for (const key of bodyOverlayKeys) { if (Object.hasOwn(generatedBody, key)) { throw new Error(`Extra request body conflicts with a generated field: ${key}`); } } - body = JSON.stringify({ ...generatedBody, ...bodyOverlay }); + const customizedBody = { ...generatedBody, ...bodyOverlay }; + body = JSON.stringify( + customization.finalizeBody ? customization.finalizeBody(customizedBody) : customizedBody, + ); nextHeaders.delete('content-length'); } return upstream(request.url, requestInit(request, nextHeaders, body)); @@ -87,15 +110,18 @@ function requestHasJsonBody(request: Request): boolean { ); } -async function parseRequestBody(request: Request): Promise> { +async function parseRequestBody( + request: Request, + invalidBodyMessage: string, +): Promise> { let parsed: unknown; try { parsed = JSON.parse(await request.clone().text()); } catch { - throw new Error('Extra request body can only be applied to a JSON object request'); + throw new Error(invalidBodyMessage); } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('Extra request body can only be applied to a JSON object request'); + throw new Error(invalidBodyMessage); } return parsed as Record; } diff --git a/packages/runtime/src/responses-reasoning-state.ts b/packages/runtime/src/responses-reasoning-state.ts new file mode 100644 index 0000000000..0303686e61 --- /dev/null +++ b/packages/runtime/src/responses-reasoning-state.ts @@ -0,0 +1,189 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ModelMessage } from './model-protocol.js'; + +const STATE_KEY = 'makaResponses'; +const STATE_VERSION = 1; +const MAX_ITEM_ID_LENGTH = 512; +const MAX_PROFILE_LENGTH = 128; +const MAX_SUMMARY_PARTS = 128; +const MAX_SUMMARY_TEXT_LENGTH = 10_000_000; + +export interface PlaintextResponsesReasoningState { + readonly version: 1; + readonly profile: string; + readonly itemId: string; + readonly summaryPartLengths: readonly number[]; +} + +export type PlaintextResponsesReasoningStateDecodeResult = + | { readonly kind: 'missing' } + | { readonly kind: 'unsupported-version'; readonly version: number } + | { readonly kind: 'malformed'; readonly profile?: string } + | { readonly kind: 'valid'; readonly state: PlaintextResponsesReasoningState }; + +export function plaintextResponsesReasoningProviderOptions( + itemId: string, + profile: string, + summaryParts: readonly string[], +): NonNullable | undefined { + if (!isSafeItemId(itemId) || !isSafeProfile(profile) || !isSafeSummaryParts(summaryParts)) { + return undefined; + } + return { + [STATE_KEY]: { + version: STATE_VERSION, + profile, + itemId, + summaryPartLengths: summaryParts.map((part) => part.length), + }, + }; +} + +export function decodePlaintextResponsesReasoningState( + providerOptions: Readonly> | undefined, +): PlaintextResponsesReasoningStateDecodeResult { + const raw = providerOptions?.[STATE_KEY]; + if (raw === undefined) return { kind: 'missing' }; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { kind: 'malformed' }; + const record = raw as Record; + const profile = isSafeProfile(record.profile) ? record.profile : undefined; + const itemId = isSafeItemId(record.itemId) ? record.itemId : undefined; + if (isSafeStateVersion(record.version) && record.version !== STATE_VERSION) { + return { kind: 'unsupported-version', version: record.version }; + } + const baseInvalid = record.version !== STATE_VERSION || !profile || !itemId; + if (baseInvalid) { + return { kind: 'malformed', ...(profile ? { profile } : {}) }; + } + if ( + !isSafeSummaryPartLengths(record.summaryPartLengths) || + Object.keys(record).some( + (key) => !['version', 'profile', 'itemId', 'summaryPartLengths'].includes(key), + ) + ) { + return { kind: 'malformed', ...(profile ? { profile } : {}) }; + } + return { + kind: 'valid', + state: { + version: STATE_VERSION, + profile, + itemId, + summaryPartLengths: record.summaryPartLengths, + }, + }; +} + +export function responsesReasoningItemId( + providerOptions: Readonly> | undefined, +): string | undefined { + const plaintext = decodePlaintextResponsesReasoningState(providerOptions); + if (plaintext.kind === 'valid') return plaintext.state.itemId; + const openai = providerOptions?.openai; + if (!openai || typeof openai !== 'object' || Array.isArray(openai)) return undefined; + const itemId = (openai as { itemId?: unknown }).itemId; + return isSafeItemId(itemId) ? itemId : undefined; +} + +export function replayPlaintextResponsesProviderOptions(input: { + providerOptionsKey: string; + state: PlaintextResponsesReasoningState; + text: string; +}): NonNullable { + return { + [input.providerOptionsKey]: { + itemId: input.state.itemId, + reasoningSummary: reconstructSummaryParts(input.text, input.state), + // Presence is meaningful to @ai-sdk/open-responses: null prevents its + // fallback from copying the canonical text into content when the + // provider replays reasoning through summary instead. + reasoningContent: null, + }, + }; +} + +export function safePlaintextResponsesReasoningItemId(value: unknown): string | undefined { + return isSafeItemId(value) ? value : undefined; +} + +function reconstructSummaryParts( + text: string, + state: PlaintextResponsesReasoningState, +): Array<{ type: 'summary_text'; text: string }> { + let offset = 0; + const parts = state.summaryPartLengths.map((length) => { + const part = { type: 'summary_text' as const, text: text.slice(offset, offset + length) }; + offset += length; + return part; + }); + if (offset !== text.length) { + throw new Error('Durable plaintext Responses reasoning summary boundaries do not match text'); + } + return parts; +} + +function isSafeItemId(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_ITEM_ID_LENGTH && + !/[\u0000-\u001f\u007f]/u.test(value) + ); +} + +function isSafeStateVersion(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function isSafeSummaryParts(value: readonly string[] | undefined): value is readonly string[] { + if (!value || value.length > MAX_SUMMARY_PARTS) return false; + let total = 0; + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) return false; + const part = value[index]; + if (typeof part !== 'string') return false; + total += part.length; + if (total > MAX_SUMMARY_TEXT_LENGTH) return false; + } + return true; +} + +function isSafeSummaryPartLengths(value: unknown): value is readonly number[] { + if (!Array.isArray(value) || value.length > MAX_SUMMARY_PARTS) return false; + let total = 0; + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) return false; + const length = value[index]; + if (!Number.isSafeInteger(length) || length < 0) return false; + total += length; + if (total > MAX_SUMMARY_TEXT_LENGTH) return false; + } + return true; +} + +function isSafeProfile(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_PROFILE_LENGTH && + !/[\u0000-\u001f\u007f]/u.test(value) + ); +}