diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 692051b124..41a5fa0a5d 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -119,6 +119,28 @@ limitations under the License. ================================================================================ +Package: @ai-sdk/open-responses@2.0.28 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: https://github.com/vercel/ai#packages/open-responses + +--- LICENSE --- +Copyright 2023 Vercel, Inc. + +Licensed 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. + +================================================================================ + Package: @ai-sdk/openai@4.0.42 Declared license: Apache-2.0 Selected license: Apache-2.0 diff --git a/package-lock.json b/package-lock.json index 273c2001f5..b36aab0132 100644 --- a/package-lock.json +++ b/package-lock.json @@ -171,6 +171,22 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/open-responses": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/@ai-sdk/open-responses/-/open-responses-2.0.28.tgz", + "integrity": "sha512-s96DcsSWGefiWNLGH6oKnkh6+T693Y9ECjjosirsvTz+QhbkDgCiiRhXS1e893qr0mPGJKFVTKRT2rDUFv4j2A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@ai-sdk/openai": { "version": "4.0.42", "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.42.tgz", @@ -13688,6 +13704,7 @@ "@ai-sdk/anthropic": "4.0.39", "@ai-sdk/cohere": "4.0.27", "@ai-sdk/google": "4.0.44", + "@ai-sdk/open-responses": "2.0.28", "@ai-sdk/openai": "4.0.42", "@ai-sdk/openai-compatible": "3.0.30", "@larksuiteoapi/node-sdk": "1.72.0", diff --git a/packages/cli/THIRD_PARTY_NOTICES.txt b/packages/cli/THIRD_PARTY_NOTICES.txt index 87b3f16a4d..2ce144b7df 100644 --- a/packages/cli/THIRD_PARTY_NOTICES.txt +++ b/packages/cli/THIRD_PARTY_NOTICES.txt @@ -119,6 +119,28 @@ limitations under the License. ================================================================================ +Package: @ai-sdk/open-responses@2.0.28 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: https://github.com/vercel/ai#packages/open-responses + +--- LICENSE --- +Copyright 2023 Vercel, Inc. + +Licensed 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. + +================================================================================ + Package: @ai-sdk/openai@4.0.42 Declared license: Apache-2.0 Selected license: Apache-2.0 diff --git a/packages/core/src/__tests__/model-web-search.test.ts b/packages/core/src/__tests__/model-web-search.test.ts index 8260e64eee..1ba879842a 100644 --- a/packages/core/src/__tests__/model-web-search.test.ts +++ b/packages/core/src/__tests__/model-web-search.test.ts @@ -7,11 +7,11 @@ describe('hosted web search capability', () => { it('enables the implemented Responses path only for declared model families', () => { assert.deepEqual(resolveHostedWebSearchCapability('deepseek', undefined, 'deepseek-v4-flash'), { adapter: 'openai-responses', - implemented: true, + implemented: false, }); assert.deepEqual(resolveHostedWebSearchCapability('deepseek', undefined, 'deepseek-v4-pro'), { adapter: 'openai-responses', - implemented: true, + implemented: false, }); assert.equal(resolveHostedWebSearchCapability('deepseek', undefined, 'deepseek-chat'), null); assert.deepEqual(resolveHostedWebSearchCapability('openai', undefined, 'gpt-5.5'), { @@ -101,7 +101,7 @@ describe('hosted web search capability', () => { it('keeps dual-wire providers on the configured connection protocol', () => { assert.deepEqual(resolveHostedWebSearchCapability('deepseek', undefined, 'deepseek-v4-flash'), { adapter: 'openai-responses', - implemented: true, + implemented: false, }); assert.deepEqual( resolveHostedWebSearchCapability( diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 815a98a274..5f0cf88707 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -25,6 +25,7 @@ import { type ProviderCategory, type ProviderDefaults, type ProviderRuntimeAdapter, + type ProviderResponsesContract, type ProviderType, } from './provider-registry.js'; @@ -44,6 +45,7 @@ export type { ProviderCategory, ProviderDefaults, ProviderRuntimeAdapter, + ProviderResponsesContract, ProviderType, }; diff --git a/packages/core/src/model-web-search.ts b/packages/core/src/model-web-search.ts index 9594764cc6..662173f3be 100644 --- a/packages/core/src/model-web-search.ts +++ b/packages/core/src/model-web-search.ts @@ -58,6 +58,10 @@ function providerHostedWebSearchAdapter( ): HostedWebSearchCapability | null { switch (providerType) { case 'deepseek': + // @ai-sdk/open-responses currently serializes function tools only. + // Mark native search unavailable so routing never hands it a provider + // tool that would be silently filtered from the request. + return { adapter: 'openai-responses', implemented: false }; case 'openai': case 'openai-responses-compatible': case 'xai': diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index faa5f5e3fb..e4f5d24abf 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -17,6 +17,16 @@ export type ProviderCatalogGroup = 'recommended' | 'plans' | 'api' | 'aggregator export type ApplyPatchProtocol = 'openai-structured' | 'codex-v4a-freeform'; +export type ProviderResponsesContract = + | { + readonly adapter: 'openai'; + readonly reasoningReplay: 'encrypted-content'; + } + | { + readonly adapter: 'open-responses'; + readonly reasoningReplay: 'plaintext-content'; + }; + type ProviderRuntimeAdapterDefinition = | { kind: 'anthropic'; auth: 'api-key' | 'bearer'; normalizeBaseUrl: boolean } | { kind: 'claude-subscription' } @@ -30,7 +40,8 @@ type ProviderRuntimeAdapterDefinition = name: 'provider' | 'connection'; includeUsage?: boolean; requireBaseUrl?: boolean; - supportsOpenAiResponses?: true; + /** Presence enables Responses and fixes the only supported SDK/replay pairing. */ + responses?: ProviderResponsesContract; replayAssistantReasoningAs?: 'reasoning'; replayAssistantReasoningDetails?: true; }; @@ -836,8 +847,8 @@ const providerRegistry = { runtimeAdapter: { kind: 'openai-compatible', name: 'provider', - supportsOpenAiResponses: true, applyPatchProtocol: 'codex-v4a-freeform', + responses: { adapter: 'open-responses', reasoningReplay: 'plaintext-content' }, }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -975,7 +986,7 @@ const providerRegistry = { runtimeAdapter: { kind: 'openai-compatible', name: 'provider', - supportsOpenAiResponses: true, + responses: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -998,7 +1009,7 @@ const providerRegistry = { runtimeAdapter: { kind: 'openai-compatible', name: 'provider', - supportsOpenAiResponses: true, + responses: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, }, modelDiscovery: { kind: 'protocol', diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 569ddce013..2cd7c401fd 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -99,7 +99,7 @@ const MAX_IMPLEMENTATION_CHILD_REQUESTS = const HEADLESS_CODING_V1_PROMPT_HASH = 'sha256:0e3389e330b8b8f0db1c7a8b8e2126325fe4c672d6eff279afcd3f9412e52271'; const HEADLESS_CODING_V1_TOOLS_HASH = - 'sha256:ea1f293096e5e209ae49346f46b0e8ff9b54ae17452a5a23149ad7233afaeafc'; + 'sha256:c062194603f93b568da5ca59b865b316156b5f218ba854c291aa9582859b3de4'; const execFileAsync = promisify(execFile); test('backend creation aborts a stalled canonical connection read', async () => { @@ -886,10 +886,11 @@ test('hosted execution freezes the headless coding provider wire contract', asyn assert.deepEqual(responsesToolNames(request?.body), [ 'ArchiveRead', 'Bash', + 'Edit', 'Glob', 'Grep', 'Read', - 'apply_patch', + 'Write', ]); const bash = (tools as Array>).find((tool) => tool.name === 'Bash'); assert.ok(bash); @@ -1956,6 +1957,83 @@ test('production Host publishes and retires an implementation child patch', asyn } }); +test('Host auxiliary calls preserve resolved DeepSeek reasoning settings', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-host-deepseek-auxiliary-')); + const provider = await startProvider(); + const capability = await resolveStorageRoot({ + path: join(base, 'interactive'), + kind: 'interactive', + }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + + try { + const policy = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const usage = await openInteractiveUsageStoresForWrite(owner.lease); + const execution = await openInteractiveExecutionStoresForWrite(owner.lease); + const created = await policy.connectionCatalog.create({ + expectedCatalogRevision: 0, + connection: { + slug: 'deepseek-auxiliary', + name: 'DeepSeek auxiliary', + providerType: 'deepseek', + baseUrl: provider.baseUrl, + enabled: true, + enabledModelIds: ['deepseek-v4-flash'], + }, + }); + assert.equal(created.kind, 'committed'); + if (created.kind !== 'committed') return; + const connection = created.snapshot.connections[0]; + assert.ok(connection); + if (!connection) return; + const credential = await policy.credentialVault.set({ + locator: { + scope: 'connection', + connectionId: connection.connectionId, + kind: 'api_key', + }, + expected: null, + secret: API_KEY, + }); + assert.equal(credential.kind, 'committed'); + await publishConnectionModel(policy, connection.connectionId, 'deepseek-v4-flash'); + const session = await execution.sessionStore.create({ + cwd: capability.canonicalPath, + backend: 'ai-sdk', + llmConnectionSlug: 'deepseek-auxiliary', + model: 'deepseek-v4-flash', + thinkingLevel: 'high', + permissionMode: 'ask', + }); + const effects = createHostSessionEffectModel({ + runtimePolicy: policy, + oauthCredentials: new HostOAuthExecutionAuthority(policy), + claudeDeviceId: capability.rootId, + usage, + requestDrain: () => assert.fail('Auxiliary telemetry must not drain the Host'), + newId: () => 'deepseek-title-call', + }); + + await effects.generateTitle({ + sessionId: session.id, + header: session, + sourceText: 'Explain the DeepSeek auxiliary reasoning seam', + abortSignal: new AbortController().signal, + }); + const request = provider.requests.at(-1); + assert.ok(request); + assert.equal(request.url, '/v1/responses'); + assert.equal(request.authorization, `Bearer ${API_KEY}`); + assert.deepEqual(request.body.reasoning, { effort: 'high' }); + } finally { + await owner.close(); + await provider.close(); + await rm(base, { recursive: true, force: true }); + } +}); + test('Host auxiliary models meter provider usage and abort physical requests', { timeout: 20_000, }, async () => { @@ -3180,6 +3258,7 @@ function responsesToolNames(body: Record | undefined): string[] } function responsesDeveloperPrompt(body: Record | undefined): string | undefined { + if (typeof body?.instructions === 'string') return body.instructions; const input = Array.isArray(body?.input) ? body.input : []; const developer = input.find( (message): message is Record => diff --git a/packages/runtime-host/src/server/execution-model-authority.ts b/packages/runtime-host/src/server/execution-model-authority.ts index 67b85695b3..3cf1dd449b 100644 --- a/packages/runtime-host/src/server/execution-model-authority.ts +++ b/packages/runtime-host/src/server/execution-model-authority.ts @@ -367,6 +367,7 @@ type AuxiliaryModelRequest = readonly maxOutputTokens: number; readonly maxRetries?: number; readonly system?: string; + readonly providerOptions?: Record; readonly tools?: never; }) | { @@ -484,6 +485,11 @@ async function runHostAuxiliaryModelCall( | Awaited>; try { result = await readDuringBackendCreation(() => { + const providerOptions = buildProviderOptions( + target.connection, + target.model, + input.header.thinkingLevel, + ); const model = getAIModel({ connection: target.connection, apiKey, @@ -499,16 +505,13 @@ async function runHostAuxiliaryModelCall( ? 'omit' : 'none', abortSignal: input.abortSignal, + providerOptions: request.providerOptions ?? providerOptions, }) : generateToolFreeModelCall({ model, ...request, abortSignal: input.abortSignal, - providerOptions: buildProviderOptions( - target.connection, - target.model, - input.header.thinkingLevel, - ), + providerOptions: request.providerOptions ?? providerOptions, }); }, input.abortSignal); const oauthFailure = readDeferredOAuthFailure?.(); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 527ceb5b66..e7ce3efa87 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -127,6 +127,7 @@ "@ai-sdk/anthropic": "4.0.39", "@ai-sdk/cohere": "4.0.27", "@ai-sdk/google": "4.0.44", + "@ai-sdk/open-responses": "2.0.28", "@ai-sdk/openai": "4.0.42", "@ai-sdk/openai-compatible": "3.0.30", "@openai/agents-core": "0.14.3", diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index b2933440c5..76957f010f 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -116,7 +116,7 @@ describe('AiSdkBackend ApplyPatch routing', () => { } }); - test('replaces Write and Edit with freeform apply_patch for declared DeepSeek V4 Flash', async () => { + test('keeps Write and Edit when DeepSeek cannot carry custom apply_patch', async () => { const model = completionModel(); const backend = createTestAiSdkBackend({ sessionId: 'session-1', @@ -143,9 +143,9 @@ describe('AiSdkBackend ApplyPatch routing', () => { await drain(backend.send({ turnId: 'turn-1', text: 'edit', context: [] })); const names = modelToolNames(model); - assert.equal(names.includes('apply_patch'), true); - assert.equal(names.includes('Write'), false); - assert.equal(names.includes('Edit'), false); + assert.equal(names.includes('apply_patch'), false); + assert.equal(names.includes('Write'), true); + assert.equal(names.includes('Edit'), true); }); test('replays a durable apply_patch failure as native provider JSON', async () => { @@ -217,20 +217,18 @@ describe('AiSdkBackend ApplyPatch routing', () => { }); }); - test('replays a durable DeepSeek freeform apply_patch result as plain text', async () => { + const assertApplyPatchHistoryDowngraded = async ( + targetConnection: LlmConnection, + modelId: string, + ) => { const model = completionModel(); const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), appendMessage: async () => {}, - connection: { - ...connection(), - slug: 'deepseek', - providerType: 'deepseek', - defaultModel: 'deepseek-v4-flash', - }, + connection: targetConnection, apiKey: 'sk-test', - modelId: 'deepseek-v4-flash', + modelId, modelFactory: () => model, tools: [nativeApplyPatchTool()], newId: idGenerator(), @@ -259,14 +257,14 @@ describe('AiSdkBackend ApplyPatch routing', () => { kind: 'function_call', id: 'call-1', name: 'apply_patch', - args: { - callId: 'call-1', - operation: { - type: 'update_file', - path: 'file.txt', - diff: '@@\n-before\n+after', - }, - }, + args: [ + '*** Begin Patch', + '*** Update File: file.txt', + '@@', + '-before', + '+after', + '*** End Patch', + ].join('\n'), }, }), runtimeEvent({ @@ -285,20 +283,42 @@ describe('AiSdkBackend ApplyPatch routing', () => { }), ); - const toolResult = (compactPrompt(model) as Array<{ role: string; content: any[] }>) - .find((message) => message.role === 'tool') - ?.content.find((part) => part.type === 'tool-result'); - const toolCall = (compactPrompt(model) as Array<{ role: string; content: any[] }>) - .find((message) => message.role === 'assistant') - ?.content.find((part) => part.type === 'tool-call'); + const replay = compactPrompt(model) as Array<{ role: string; content: any[] }>; assert.equal( - toolCall?.input, - '*** Begin Patch\n*** Update File: file.txt\n@@\n-before\n+after\n*** End Patch', + replay.some((message) => + message.content.some( + (part) => part.type === 'tool-call' && part.toolName === 'apply_patch', + ), + ), + false, + ); + assert.equal( + replay.some((message) => message.role === 'tool'), + false, + ); + assert.match( + replay + .flatMap((message) => message.content) + .find((part) => part.type === 'text' && /ApplyPatch completed/.test(part.text))?.text ?? '', + /ApplyPatch completed 1 file operation: update_file file\.txt/, + ); + }; + + test('downgrades durable DeepSeek freeform apply_patch history to a fact', async () => { + await assertApplyPatchHistoryDowngraded( + { + ...connection(), + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + 'deepseek-v4-flash', ); - assert.deepEqual(toolResult?.output, { - type: 'text', - value: 'Applied 1 file operation.', - }); + }); + + test('downgrades apply_patch history when a non-Responses target does not advertise it', async () => { + const targetConnection = connection(); + await assertApplyPatchHistoryDowngraded(targetConnection, targetConnection.defaultModel!); }); test('preserves a multi-file ApplyPatch fact when structured replay cannot represent it', async () => { @@ -3006,6 +3026,203 @@ describe('AiSdkBackend model history', () => { assert.match(JSON.stringify(assistant), /Maka shipped the feature/); }); + test('falls back to grounded text when Open Responses cannot replay a hosted tool pair', async () => { + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + apiKey: 'deepseek-token', + modelId: 'deepseek-v4-flash', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: '', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u-search', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'search', + }), + runtimeEvent({ + id: 'rt-search-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { stepId: 'provider-step' }, + content: { + kind: 'function_call', + id: 'search-1', + name: 'WebSearch', + args: { query: 'latest Maka' }, + providerExecuted: true, + }, + }), + runtimeEvent({ + id: 'rt-search-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'search-1', + name: 'WebSearch', + result: { type: 'web_search_result', query: 'latest Maka' }, + providerExecuted: true, + isError: false, + }, + }), + runtimeEvent({ + id: 'rt-search-text', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { providerEventId: 'provider-step' }, + content: { kind: 'text', text: 'Maka shipped the feature.' }, + }), + ], + continuation: { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-prev', + sourceRuntimeEventHighWater: 4, + }, + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; + assert.match(JSON.stringify(prompt), /Maka shipped the feature/); + assert.equal(JSON.stringify(prompt).includes('tool-call'), false); + assert.equal(JSON.stringify(prompt).includes('tool-result'), false); + }); + + test('keeps unrelated client tool history when degrading a hosted tool pair', async () => { + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + apiKey: '[redacted]', + modelId: 'deepseek-v4-flash', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: '', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u-mixed', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'read then search', + }), + runtimeEvent({ + id: 'rt-read-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { stepId: 'client-step' }, + content: { + kind: 'function_call', + id: 'read-1', + name: 'Read', + args: { path: '/tmp/sentinel.ts' }, + }, + }), + runtimeEvent({ + id: 'rt-read-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'read-1', + name: 'Read', + result: [{ type: 'text', text: 'CLIENT_READ_SENTINEL_CONTENT' }], + isError: false, + }, + }), + runtimeEvent({ + id: 'rt-search-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { stepId: 'provider-step' }, + content: { + kind: 'function_call', + id: 'search-1', + name: 'WebSearch', + args: { query: 'latest Maka' }, + providerExecuted: true, + }, + }), + runtimeEvent({ + id: 'rt-search-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'search-1', + name: 'WebSearch', + result: { type: 'web_search_result', query: 'latest Maka' }, + providerExecuted: true, + isError: false, + }, + }), + runtimeEvent({ + id: 'rt-mixed-text', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { providerEventId: 'provider-step' }, + content: { kind: 'text', text: 'Maka shipped the feature.' }, + }), + ], + continuation: { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-prev', + sourceRuntimeEventHighWater: 6, + }, + }), + ); + + const wire = JSON.stringify(compactPrompt(model)); + // The unsupported provider-executed pair degrades away… + assert.equal(wire.includes('latest Maka'), false, wire); + // …but the unrelated client Read call and its result survive (#2972). + assert.match(wire, /CLIENT_READ_SENTINEL_CONTENT/); + assert.match(wire, /"toolName":"Read"|\\"toolName\\":\\"Read\\"/); + assert.match(wire, /Maka shipped the feature/); + }); + test('replays an image tool result as provider image data', async () => { const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]); const model = completionModel(); @@ -12233,14 +12450,14 @@ describe('AiSdkBackend thinking persistence', () => { test('omits Responses reasoning without encrypted content from the wire request', async (t) => { for (const replayCase of [ - { name: 'missing', openai: { itemId: 'rs_deepseek' } }, + { name: 'missing', openai: { itemId: 'rs_openai' } }, { name: 'null', - openai: { itemId: 'rs_deepseek', reasoningEncryptedContent: null }, + openai: { itemId: 'rs_openai', reasoningEncryptedContent: null }, }, { name: 'empty string', - openai: { itemId: 'rs_deepseek', reasoningEncryptedContent: '' }, + openai: { itemId: 'rs_openai', reasoningEncryptedContent: '' }, }, ] as const) { await t.test(replayCase.name, async () => { @@ -12252,7 +12469,7 @@ describe('AiSdkBackend thinking persistence', () => { author: 'agent', content: { kind: 'thinking', - text: 'plaintext reasoning from DeepSeek', + text: 'display-only reasoning without an encrypted replay payload', providerOptions: { openai: replayCase.openai }, }, refs: { providerEventId: 'm1' }, @@ -12303,7 +12520,7 @@ describe('AiSdkBackend thinking persistence', () => { id: 'response-current', object: 'response', created_at: 8, - model: 'deepseek-v4-flash', + model: 'gpt-5.5', status: 'completed', output: [], usage: { input_tokens: 1, output_tokens: 1 }, @@ -12323,12 +12540,12 @@ describe('AiSdkBackend thinking persistence', () => { header: header(), appendMessage: async () => {}, connection: { - slug: 'deepseek', - providerType: 'deepseek', - defaultModel: 'deepseek-v4-flash', + slug: 'openai', + providerType: 'openai', + defaultModel: 'gpt-5.5', }, - apiKey: 'deepseek-test-token', - modelId: 'deepseek-v4-flash', + apiKey: 'openai-test-token', + modelId: 'gpt-5.5', modelFactory: (input) => getAIModel({ ...input, fetch }), tools: [], newId: idGenerator(), @@ -12448,11 +12665,20 @@ describe('AiSdkBackend thinking persistence', () => { }, }, { - type: 'text_complete', + type: 'thinking_complete', id: 'e4', turnId: 'turn-prev', ts: 4, messageId: 'm1', + text: 'unreplayable OpenAI reasoning', + providerOptions: { openai: { itemId: 'rs_without_encrypted_content' } }, + }, + { + type: 'text_complete', + id: 'e5', + turnId: 'turn-prev', + ts: 5, + messageId: 'm1', text: '', }, ]; @@ -12491,22 +12717,173 @@ describe('AiSdkBackend thinking persistence', () => { (message) => message.role === 'assistant' && Array.isArray(message.content), ); assert.ok(assistant && Array.isArray(assistant.content)); - const reasoning = assistant.content.find((part) => part.type === 'reasoning'); - assert.deepEqual(reasoning, { - type: 'reasoning', - text: 'reasoning about the tool', - providerOptions: { - openai: { - itemId: 'rs_ark', - reasoningEncryptedContent: 'encrypted-ark-reasoning', + assert.deepEqual( + assistant.content.filter((part) => part.type === 'reasoning'), + [ + { + type: 'reasoning', + text: 'reasoning about the tool', + providerOptions: { + openai: { + itemId: 'rs_ark', + reasoningEncryptedContent: 'encrypted-ark-reasoning', + }, + }, }, + ], + ); + assert.ok( + assistant.content.some((part) => part.type === 'tool-call' && part.toolCallId === 'tool-1'), + ); + }); + + test('DeepSeek Responses replays plaintext reasoning without an OpenAI item id', async () => { + 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 priorEvents: SessionEvent[] = [ + { + type: 'tool_start', + id: 'e1', + turnId: 'turn-prev', + ts: 1, + toolUseId: 'tool-1', + toolName: 'Read', + args: { path: 'package.json' }, + stepId: 'm1', + }, + { + type: 'tool_result', + id: 'e2', + turnId: 'turn-prev', + ts: 2, + toolUseId: 'tool-1', + isError: false, + content: { kind: 'text', text: 'file contents' }, + }, + { + type: 'thinking_complete', + id: 'e3', + turnId: 'turn-prev', + ts: 3, + messageId: 'm1', + text: 'reasoning about the tool', }, + { + type: 'thinking_complete', + id: 'e3-empty', + turnId: 'turn-prev', + ts: 3, + messageId: 'm1', + text: '', + }, + { + type: 'text_complete', + id: 'e4', + turnId: 'turn-prev', + ts: 4, + messageId: 'm1', + text: '', + }, + ]; + const runtimeContext = priorEvents.map((event) => + mapSessionEventToRuntimeEvent(event, ctx, memory), + ); + const secondModel = completionModel(); + const secondBackend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + apiKey: 'deepseek-token', + modelId: 'deepseek-v4-flash', + 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)); + const reasoningParts = assistant.content.filter((part) => part.type === 'reasoning'); + assert.equal(reasoningParts.length, 1); + const reasoning = reasoningParts[0]; + assert.ok(reasoning && reasoning.type === 'reasoning'); + assert.equal(reasoning.text, 'reasoning about the tool'); assert.ok( assistant.content.some((part) => part.type === 'tool-call' && part.toolCallId === 'tool-1'), ); }); + 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) => { + requestBody = JSON.parse(String(init?.body)) as Record; + const events = [ + { type: 'response.created', response: { id: 'response-current' } }, + { + type: 'response.completed', + response: { + id: 'response-current', + object: 'response', + created_at: 8, + model: 'deepseek-v4-flash', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + ]; + return new Response( + `${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ); + }) as unknown as typeof globalThis.fetch; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: { ...header(), thinkingLevel: 'max' }, + appendMessage: async () => {}, + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + apiKey: 'deepseek-test-token', + modelId: 'deepseek-v4-flash', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain(backend.send({ turnId: 'turn-current', text: 'think', context: [] })); + + assert.deepEqual(requestBody?.reasoning, { effort: 'max' }); + assert.equal(requestBody?.include, undefined); + }); + test('preserves every OpenAI Responses reasoning item through stream persistence and replay', async () => { const chunks: LanguageModelV4StreamPart[] = [ { type: 'stream-start', warnings: [] }, diff --git a/packages/runtime/src/__tests__/apply-patch-profile.test.ts b/packages/runtime/src/__tests__/apply-patch-profile.test.ts index f4b0cc9ef9..cf6c762343 100644 --- a/packages/runtime/src/__tests__/apply-patch-profile.test.ts +++ b/packages/runtime/src/__tests__/apply-patch-profile.test.ts @@ -3,12 +3,14 @@ import { describe, test } from 'node:test'; import { normalizeApplyPatchReplayInput, resolveApplyPatchProfile, + routeApplyPatchTools, } from '../apply-patch-profile.js'; +import type { MakaTool } from '../tool-runtime.js'; import { resolveModelRuntime } from '../model-runtime.js'; describe('ApplyPatch profile routing', () => { test('derives the effective profile from the provider adapter contract', () => { - assert.deepEqual( + assert.equal( resolveModelRuntime( { providerType: 'deepseek', @@ -16,11 +18,11 @@ describe('ApplyPatch profile routing', () => { }, 'deepseek-v4-flash', ).applyPatchProfile, - { kind: 'codex-v4a-freeform' }, + null, ); - assert.deepEqual( + assert.equal( resolveModelRuntime({ providerType: 'deepseek' }, 'deepseek-v4-pro').applyPatchProfile, - { kind: 'codex-v4a-freeform' }, + null, ); assert.equal( resolveModelRuntime({ providerType: 'xai' }, 'deepseek-v4-flash').applyPatchProfile, @@ -28,13 +30,35 @@ describe('ApplyPatch profile routing', () => { ); }); - test('selects Codex V4A freeform for declared DeepSeek V4 Responses models', () => { + test('keeps portable Write/Edit when DeepSeek cannot carry custom ApplyPatch', () => { + const tool = (name: string, providerTool?: MakaTool['providerTool']): MakaTool => ({ + name, + description: name, + parameters: {}, + providerTool, + impl: async () => undefined, + }); + const routed = routeApplyPatchTools( + [tool('Write'), tool('Edit'), tool('apply_patch', { kind: 'openai-apply-patch' })], + resolveModelRuntime({ providerType: 'deepseek' }, 'deepseek-v4-flash').applyPatchProfile, + ); + assert.deepEqual( + routed.map(({ name }) => name), + ['Write', 'Edit'], + ); + }); + + test('does not expose the dormant Codex V4A freeform target path', () => { + assert.equal( resolveApplyPatchProfile( - { wire: 'openai-responses', applyPatchProtocol: 'codex-v4a-freeform' }, + { + wire: 'openai-responses', + applyPatchProtocol: 'codex-v4a-freeform', + }, 'deepseek-v4-flash', ), - { kind: 'codex-v4a-freeform' }, + null, ); assert.equal( resolveApplyPatchProfile( @@ -43,12 +67,15 @@ describe('ApplyPatch profile routing', () => { ), null, ); - assert.deepEqual( + assert.equal( resolveApplyPatchProfile( - { wire: 'openai-responses', applyPatchProtocol: 'codex-v4a-freeform' }, + { + wire: 'openai-responses', + applyPatchProtocol: 'codex-v4a-freeform', + }, 'deepseek-v4-pro', ), - { kind: 'codex-v4a-freeform' }, + null, ); assert.equal(resolveApplyPatchProfile({ wire: 'openai-responses' }, 'deepseek-v4-flash'), null); }); @@ -79,6 +106,14 @@ describe('ApplyPatch profile routing', () => { }); test('normalizes portable single-operation history', () => { + assert.equal( + normalizeApplyPatchReplayInput( + null, + 'call-1', + '*** Begin Patch\n*** Delete File: old.txt\n*** End Patch', + ), + null, + ); assert.deepEqual( normalizeApplyPatchReplayInput( { kind: 'openai-structured' }, @@ -90,12 +125,5 @@ describe('ApplyPatch profile routing', () => { operation: { type: 'delete_file', path: 'old.txt' }, }, ); - assert.equal( - normalizeApplyPatchReplayInput({ kind: 'codex-v4a-freeform' }, 'call-1', { - callId: 'call-1', - operation: { type: 'delete_file', path: 'old.txt' }, - }), - '*** Begin Patch\n*** Delete File: old.txt\n*** End Patch', - ); }); }); diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index fec15a4b85..67aa5efe8c 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -65,9 +65,10 @@ describe('ModelAdapter stream and error normalization', () => { assert.deepEqual(adapter.runtimeEventReplaySupport(), { toolCalls: true, toolResults: true, + providerExecutedTools: true, signedThinking: false, unsignedThinking: true, - openAiResponsesEncryptedThinking: false, + responsesReasoning: 'none', }); }); @@ -119,9 +120,34 @@ describe('ModelAdapter stream and error normalization', () => { assert.deepEqual(adapter.runtimeEventReplaySupport(), { toolCalls: true, toolResults: true, + providerExecutedTools: true, signedThinking: false, unsignedThinking: false, - openAiResponsesEncryptedThinking: true, + responsesReasoning: 'encrypted-content', + }); + }); + + test('supports plaintext Responses reasoning replay for DeepSeek V4', () => { + const adapter = new ModelAdapter({ + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + apiKey: 'deepseek-token', + modelId: 'deepseek-v4-flash', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + + assert.deepEqual(adapter.runtimeEventReplaySupport(), { + toolCalls: true, + toolResults: true, + providerExecutedTools: false, + signedThinking: false, + unsignedThinking: false, + responsesReasoning: 'plaintext-content', }); }); diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 59db3c82f0..919ac5e78c 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -3,7 +3,7 @@ import { describe, test } from 'node:test'; import type { LlmConnection } from '@maka/core/llm-connections'; import { thinkingVariantsForModel, type ThinkingLevel } from '@maka/core/model-thinking'; -import { buildProviderOptions, getAIModel } from '@maka/runtime/model-factory'; +import { buildProviderOptions, getAIModel } from '../model-factory.js'; function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmConnection { return { @@ -203,20 +203,21 @@ describe('buildProviderOptions: thinking level', () => { [...thinkingVariantsForModel('deepseek', 'deepseek-v4-flash')], ['high', 'max'], ); - // deepseek-v4-flash serves the Responses wire, which the native OpenAI - // provider dials: its namespace is `openai`, and the provider's own - // namespace would be dropped on the floor. `store: false` and - // `forceReasoning` are what earn the encrypted reasoning the next step - // replays, so they hold even when no level was picked. + // DeepSeek V4 uses the generic Open Responses adapter, which passes a + // provider-native reasoningEffort through verbatim: `max` stays `max` + // (DeepSeek's documented mapping sends `xhigh` to high, not max). assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'high'), { - openai: { store: false, forceReasoning: true, reasoningEffort: 'high' }, + deepseek: { reasoningEffort: 'high' }, }); assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'max'), { - openai: { store: false, forceReasoning: true, reasoningEffort: 'max' }, - }); - assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'off'), { - openai: { store: false, forceReasoning: true }, + deepseek: { reasoningEffort: 'max' }, }); + for (const unsupported of ['off', 'low', 'medium', 'minimal'] as const) { + assert.deepEqual( + buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', unsupported), + {}, + ); + } assert.deepEqual([...thinkingVariantsForModel('zai-coding-plan', 'glm-5.1')], []); assert.deepEqual([...thinkingVariantsForModel('zai-coding-plan', 'glm-4.5-air')], []); // miss model (deepseek-chat non-reasoning) drops level @@ -499,7 +500,7 @@ describe('buildProviderOptions: openai-compatible namespace', () => { { 'zai-coding-plan': { reasoningEffort: 'max' } }, ); }); - test('deepseek uses its own raw namespace on the chat wire, the OpenAI one on Responses', () => { + test('deepseek wires provider-native effort on both chat and Responses dialects', () => { const chatConnection: LlmConnection = { ...conn('deepseek', 'deepseek'), models: [{ id: 'deepseek-v4-pro', apiProtocol: 'openai-chat' }], @@ -507,9 +508,13 @@ describe('buildProviderOptions: openai-compatible namespace', () => { assert.deepEqual(buildProviderOptions(chatConnection, 'deepseek-v4-pro', 'high'), { deepseek: { reasoningEffort: 'high' }, }); + // The Responses wire keys the same effort under the raw provider name the + // Open Responses SDK resolves (no camelCase alias on that package). assert.deepEqual( buildProviderOptions(conn('deepseek', 'deepseek'), 'deepseek-v4-flash', 'high'), - { openai: { store: false, forceReasoning: true, reasoningEffort: 'high' } }, + { + deepseek: { reasoningEffort: 'high' }, + }, ); }); diff --git a/packages/runtime/src/__tests__/native-web-search-tool.test.ts b/packages/runtime/src/__tests__/native-web-search-tool.test.ts index 98d949567e..968a1c41ff 100644 --- a/packages/runtime/src/__tests__/native-web-search-tool.test.ts +++ b/packages/runtime/src/__tests__/native-web-search-tool.test.ts @@ -19,7 +19,7 @@ test('native WebSearch is a provider-executed descriptor, not a local implementa assert.throws(() => tool.impl({}, {} as never), /must not execute through ToolRuntime/); }); -test('turn-start routing keeps native and client-executed search mutually exclusive', () => { +test('turn-start routing falls back explicitly when native search is unavailable', () => { const clientSearch = { name: NATIVE_WEB_SEARCH_TOOL_NAME, description: 'Tavily', @@ -46,10 +46,9 @@ test('turn-start routing keeps native and client-executed search mutually exclus model: 'deepseek-v4-flash', tavilyReady: false, }); - assert.equal(native.filter((tool) => tool.name === NATIVE_WEB_SEARCH_TOOL_NAME).length, 1); - assert.equal( - native.find((tool) => tool.name === NATIVE_WEB_SEARCH_TOOL_NAME)?.providerTool?.kind, - 'openai-web-search', + assert.deepEqual( + native.map((tool) => tool.name), + ['Read'], ); const external = routeWebSearchTools({ @@ -127,7 +126,7 @@ test('turn-start routing compiles Claude models to the CC-compatible Anthropic t }); }); -test('root surfaces may add native search without widening scoped child tools', () => { +test('root surfaces do not advertise unsupported DeepSeek native search', () => { const connection = { slug: 'deepseek', providerType: 'deepseek' as const, @@ -141,7 +140,7 @@ test('root surfaces may add native search without widening scoped child tools', tavilyReady: false, allowAddNative: true, }); - assert.equal(root[0]?.providerTool?.kind, 'openai-web-search'); + assert.deepEqual(root, []); const child = routeWebSearchTools({ tools: [], 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 575a43f6b1..d9c7b66ec1 100644 --- a/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts +++ b/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts @@ -1,8 +1,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import type { LlmConnection } from '@maka/core/llm-connections'; -import { getAIModel } from '@maka/runtime/model-factory'; -import { createOpenAiResponsesPlaintextReasoningTransport } from '../openai-responses-plaintext-reasoning-transport.js'; +import { buildProviderOptions, getAIModel } from '../model-factory.js'; function conn(providerType: LlmConnection['providerType']): LlmConnection { return { @@ -111,6 +110,54 @@ function deepseekReasoningStream(deltas: string[], answer = ANSWER): string { return `${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`; } +function standardFunctionCallStream(): string { + const item = { + type: 'function_call', + id: 'fc_1', + call_id: 'call_1', + name: 'Read', + arguments: '{"path":"package.json"}', + status: 'completed', + }; + const events = [ + { type: 'response.created', sequence_number: 0, response: { id: 'r' } }, + { + type: 'response.output_item.added', + sequence_number: 1, + output_index: 0, + item: { ...item, arguments: '', status: 'in_progress' }, + }, + { + type: 'response.function_call_arguments.done', + sequence_number: 2, + output_index: 0, + item_id: item.id, + call_id: item.call_id, + arguments: item.arguments, + }, + { + type: 'response.output_item.done', + sequence_number: 3, + output_index: 0, + item, + }, + { + type: 'response.completed', + sequence_number: 4, + response: { + id: 'r', + object: 'response', + created_at: 0, + model: 'deepseek-v4-flash', + status: 'completed', + output: [item], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + ]; + return `${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`; +} + /** * Chunks are cut from the encoded bytes, not from the string: slicing the * string would hand every chunk a whole character and quietly make multi-byte @@ -133,21 +180,6 @@ function sseFetch(body: string, chunkSize = Number.MAX_SAFE_INTEGER): typeof glo }) as unknown as typeof globalThis.fetch; } -/** A stream cut short by `missingBytes`, as a dropped connection would leave it. */ -function truncatingFetch(body: string, missingBytes: number): typeof globalThis.fetch { - const bytes = new TextEncoder().encode(body); - return (async () => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(bytes.slice(0, bytes.length - missingBytes)); - controller.close(); - }, - }), - { status: 200, headers: { 'content-type': 'text/event-stream' } }, - )) as unknown as typeof globalThis.fetch; -} - async function streamParts( providerType: LlmConnection['providerType'], fetch: typeof globalThis.fetch, @@ -160,7 +192,10 @@ async function streamParts( }); const { stream } = await model.doStream({ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], - providerOptions: { openai: { store: false, forceReasoning: true } }, + providerOptions: + providerType === 'deepseek' + ? buildProviderOptions(conn(providerType), 'deepseek-v4-flash', 'high') + : { openai: { store: false, forceReasoning: true } }, }); let reasoning = ''; let text = ''; @@ -211,6 +246,46 @@ describe('open responses plaintext reasoning', () => { assert.equal(parts.text, ANSWER); }); + test('ordinary function calls still finish as tool-calls', async () => { + const model = getAIModel({ + connection: conn('deepseek'), + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch: sseFetch(standardFunctionCallStream()), + }); + const { stream } = await model.doStream({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'read package.json' }] }], + tools: [ + { + type: 'function', + name: 'Read', + inputSchema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + additionalProperties: false, + }, + }, + ], + providerOptions: buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'high'), + }); + + const parts = []; + for await (const part of stream) parts.push(part); + assert.deepEqual( + parts.find((part) => part.type === 'tool-call'), + { + type: 'tool-call', + toolCallId: 'call_1', + toolName: 'Read', + input: '{\"path\":\"package.json\"}', + // 2.0.28 preserves the provider item identity used by ordered replay. + providerMetadata: { deepseek: { itemId: 'fc_1' } }, + }, + ); + assert.equal(parts.find((part) => part.type === 'finish')?.finishReason.unified, 'tool-calls'); + }); + test('non-streaming reasoning content is read', async () => { let body: string | undefined; const fetch = (async () => { @@ -243,110 +318,10 @@ describe('open responses plaintext reasoning', () => { }); const result = await model.doGenerate({ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], - providerOptions: { openai: { store: false, forceReasoning: true } }, + providerOptions: buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'high'), }); const reasoning = result.content.filter((part) => part.type === 'reasoning'); assert.equal(reasoning.length, 1); assert.equal(reasoning[0].text, REASONING); }); - - test('the position of a reasoning part is carried across, not flattened', async () => { - // Read at the transport rather than end to end: the SDK opens a second - // reasoning part only on `reasoning_summary_part.added`, which no measured - // provider sends, so a fixture producing one would describe nobody. What - // the transport owns is narrower and testable on its own — `content_index` - // names the same position `summary_index` does, and collapsing it to 0 - // would merge parts the provider kept apart. - const source = [ - `data: ${JSON.stringify({ type: 'response.reasoning_text.delta', content_index: 2, delta: 'x', item_id: ITEM_ID })}`, - 'data: [DONE]', - '', - ].join('\n\n'); - const translated = createOpenAiResponsesPlaintextReasoningTransport(sseFetch(source))( - 'https://example.invalid', - ); - const body = await (await translated).text(); - const event = JSON.parse( - body - .split('\n') - .find((line) => line.includes('summary_index')) - ?.slice('data: '.length) ?? '', - ); - assert.equal(event.type, 'response.reasoning_summary_text.delta'); - assert.equal(event.summary_index, 2); - assert.equal('content_index' in event, false); - }); - - test('a truncated body does not swallow the bytes it cut through', async () => { - // A character split across a chunk boundary completes when the next chunk - // lands, so only a body that ends mid-sequence leaves bytes inside the - // decoder. Those bytes belong to the caller either way: released, they - // surface as a replacement character; held, they vanish with no trace that - // the stream was cut. Read at the transport because the SDK's event parser - // discards an unterminated final line whatever it holds. - const truncated = truncatingFetch(`data: 合数`, 1); - const translated = - await createOpenAiResponsesPlaintextReasoningTransport(truncated)('https://example.invalid'); - assert.equal(await translated.text(), 'data: 合�'); - }); - - test('rewritten bodies do not keep the old body framing headers', async () => { - // The body is re-encoded, so a copied `content-length` describes something - // that no longer exists. - const source = `data: ${JSON.stringify({ type: 'response.reasoning_text.delta', content_index: 0, delta: 'x', item_id: ITEM_ID })}\n\n`; - const framed = (async () => - new Response(source, { - status: 200, - headers: { - 'content-type': 'text/event-stream', - 'content-length': String(source.length), - 'content-encoding': 'gzip', - }, - })) as unknown as typeof globalThis.fetch; - const translated = - await createOpenAiResponsesPlaintextReasoningTransport(framed)('https://example.invalid'); - assert.equal(translated.headers.get('content-length'), null); - assert.equal(translated.headers.get('content-encoding'), null); - assert.equal(translated.headers.get('content-type'), 'text/event-stream'); - }); - - test('a summary the provider populated itself is left alone', async () => { - // Filling a gap is safe; overwriting is not. A provider that speaks both - // shapes keeps whatever it chose to put in the summary. - const fetch = (async () => - new Response( - JSON.stringify({ - id: 'r', - object: 'response', - created_at: 0, - model: 'deepseek-v4-flash', - status: 'completed', - output: [ - { - type: 'reasoning', - id: ITEM_ID, - summary: [{ type: 'summary_text', text: 'provider summary' }], - content: [{ type: 'reasoning_text', text: REASONING }], - }, - ], - usage: { input_tokens: 1, output_tokens: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - )) as unknown as typeof globalThis.fetch; - const model = getAIModel({ - connection: conn('deepseek'), - apiKey: 'test-key', - modelId: 'deepseek-v4-flash', - fetch, - }); - const result = await model.doGenerate({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], - providerOptions: { openai: { store: false, forceReasoning: true } }, - }); - const reasoning = result.content.filter((part) => part.type === 'reasoning'); - assert.deepEqual( - reasoning.map((part) => part.text), - ['provider summary'], - ); - }); }); diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index 1a4c61b2bf..09526e4e29 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import type { IncomingMessage } from 'node:http'; import { after, describe, test } from 'node:test'; import { PROVIDER_DEFAULTS, type LlmConnection } from '@maka/core/llm-connections'; -import { openai } from '@ai-sdk/openai'; import { anthropic } from '@ai-sdk/anthropic'; import { generateText, isStepCount, streamText, tool, type ModelMessage } from 'ai'; import { z } from 'zod'; @@ -615,42 +614,19 @@ describe('models.dev provider conformance', () => { assert.equal(gpt4o.text, 'Chat wire.'); }); - test('DeepSeek V4 Flash uses Responses and accepts provider-native web search', async () => { + test('DeepSeek V4 Flash uses standard Responses function tools', async () => { let requestBody: Record | undefined; let requestUrl: string | undefined; - let authorization: string | undefined; const server = await startJsonServer(async (request, response) => { requestUrl = request.url; - authorization = request.headers.authorization; requestBody = JSON.parse(await readBody(request)) as Record; respondJson(response, 200, { - id: 'resp_deepseek_search', + id: 'resp_deepseek_tool', object: 'response', created_at: 1, status: 'completed', model: 'deepseek-v4-flash', - output: [ - { - type: 'web_search_call', - id: 'search_deepseek', - status: 'completed', - action: { type: 'search', queries: ['latest Maka'] }, - }, - { - type: 'message', - id: 'msg_deepseek', - status: 'completed', - role: 'assistant', - content: [ - { - type: 'output_text', - text: 'Search complete.', - annotations: [], - logprobs: [], - }, - ], - }, - ], + output: [], usage: { input_tokens: 8, output_tokens: 3, total_tokens: 11 }, }); }); @@ -671,103 +647,28 @@ describe('models.dev provider conformance', () => { apiKey: 'deepseek-test-key', modelId: connection.defaultModel, }), - prompt: 'Search.', - tools: { WebSearch: openai.tools.webSearch() }, + prompt: 'Read a file.', + tools: { + Read: tool({ description: 'Read', inputSchema: z.object({ path: z.string() }) }), + }, maxRetries: 0, }); assert.equal(requestUrl, '/responses'); - assert.equal(authorization, 'Bearer deepseek-test-key'); - assert.deepEqual(requestBody?.tools, [{ type: 'web_search' }]); - }); - - test('DeepSeek Responses replays hosted web search as an item reference, not an orphan output', async () => { - let requestBody: Record | undefined; - const server = await startJsonServer(async (request, response) => { - requestBody = JSON.parse(await readBody(request)) as Record; - respondJson(response, 200, { - id: 'resp_deepseek_search_replay', - object: 'response', - created_at: 2, - status: 'completed', - model: 'deepseek-v4-flash', - output: [ - { - type: 'message', - id: 'msg_deepseek_replay', - status: 'completed', - role: 'assistant', - content: [ - { - type: 'output_text', - text: 'Replay complete.', - annotations: [], - logprobs: [], - }, - ], - }, - ], - usage: { input_tokens: 8, output_tokens: 3, total_tokens: 11 }, - }); - }); - const connection: LlmConnection = { - slug: 'deepseek-search-replay', - name: 'DeepSeek Search Replay', - providerType: 'deepseek', - baseUrl: server.url, - defaultModel: 'deepseek-v4-flash', - enabled: true, - createdAt: 1, - updatedAt: 1, - }; - const messages: ModelMessage[] = [ - { role: 'user', content: 'Search.' }, + assert.deepEqual(requestBody?.tools, [ { - role: 'assistant', - content: [ - { - type: 'tool-call', - toolCallId: 'search_deepseek', - toolName: 'WebSearch', - input: {}, - providerExecuted: true, - }, - { - type: 'tool-result', - toolCallId: 'search_deepseek', - toolName: 'WebSearch', - output: { - type: 'json', - value: { action: { type: 'search', queries: ['latest Maka'] } }, - }, - }, - ], + type: 'function', + name: 'Read', + description: 'Read', + parameters: { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + additionalProperties: false, + }, }, - { role: 'user', content: 'Continue without searching.' }, - ]; - - await generateText({ - model: getAIModel({ - connection, - apiKey: 'deepseek-test-key', - modelId: connection.defaultModel, - }), - messages, - tools: { WebSearch: openai.tools.webSearch() }, - maxRetries: 0, - }); - - const input = requestBody?.input as Array> | undefined; - assert.equal( - input?.some((item) => item.type === 'function_call_output'), - false, - JSON.stringify(input), - ); - assert.equal( - input?.some((item) => item.type === 'item_reference' && item.id === 'search_deepseek'), - true, - JSON.stringify(input), - ); + ]); }); test('OpenCode Zen routes GPT through Responses and preserves tool results across both stages', async () => { @@ -954,6 +855,28 @@ describe('models.dev provider conformance', () => { assert.equal(body?.store, false); }); + test('an Open Responses probe normalizes a base URL that already names the endpoint', async () => { + let probedPath: string | undefined; + const server = await startJsonServer(async (request, response) => { + assert.equal(request.method, 'POST'); + probedPath = request.url; + respondJson(response, 200, {}); + }); + const connection: LlmConnection = { + slug: 'deepseek', + name: 'DeepSeek', + providerType: 'deepseek', + baseUrl: `${server.url}/v1/responses`, + defaultModel: 'deepseek-v4-pro', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + assert.equal((await testConnection(connection, 'deepseek-token')).ok, true); + assert.equal(probedPath, '/v1/responses'); + }); + test('Ollama Cloud requests usage in streamed chat completions', async () => { let requestBody: Record | undefined; const server = await startJsonServer(async (request, response) => { diff --git a/packages/runtime/src/__tests__/provider-contract-matrix.ts b/packages/runtime/src/__tests__/provider-contract-matrix.ts index 895952a474..e28914c85f 100644 --- a/packages/runtime/src/__tests__/provider-contract-matrix.ts +++ b/packages/runtime/src/__tests__/provider-contract-matrix.ts @@ -198,7 +198,7 @@ function usesOpenAiResponsesWire( const adapter = def.runtimeAdapter; const supportsResponses = adapter.kind === 'openai' || - (adapter.kind === 'openai-compatible' && adapter.supportsOpenAiResponses === true); + (adapter.kind === 'openai-compatible' && adapter.responses !== undefined); return ( supportsResponses && openAiAdapterApiProtocol(modelId, providerType) === 'openai-responses' ); @@ -369,6 +369,15 @@ function reasoningReplayCell( contract: `${adapter.kind} replays reasoning on its provider-specific per-model wire`, }; } + if (adapter.kind === 'openai-compatible' && adapter.responses !== undefined) { + return { + state: 'override', + dimension: 'reasoning-replay', + overrideKey: overrideKeyFor(providerType, 'reasoning-replay'), + contract: + 'The explicitly declared Responses adapter owns its provider-specific continuation representation', + }; + } if (adapter.kind === 'openai-compatible') { if (adapter.replayAssistantReasoningDetails === true) { return { diff --git a/packages/runtime/src/__tests__/provider-contract-overrides.ts b/packages/runtime/src/__tests__/provider-contract-overrides.ts index 5cfef51907..b1371bc96d 100644 --- a/packages/runtime/src/__tests__/provider-contract-overrides.ts +++ b/packages/runtime/src/__tests__/provider-contract-overrides.ts @@ -79,7 +79,11 @@ export const PROVIDER_CONTRACT_OVERRIDE_BINDINGS: readonly ProviderContractOverr run: runZenMuxSignedReasoningReplay, }, { - keys: ['openai-responses-compatible:exact-model-id', 'openai-responses-compatible:tool-loop'], + keys: [ + 'openai-responses-compatible:exact-model-id', + 'openai-responses-compatible:tool-loop', + 'openai-responses-compatible:reasoning-replay', + ], title: 'Custom OpenAI Responses relay preserves exact model ids and tool results', run: () => runOpenAIResponsesWire({ @@ -89,6 +93,7 @@ export const PROVIDER_CONTRACT_OVERRIDE_BINDINGS: readonly ProviderContractOverr basePath: '/relay/v1', modelId: 'relay-responses-model', apiKey: 'responses-relay-key', + statelessReasoning: true, }), }, { diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index 1d89f6592b..5dddbe918f 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -5,12 +5,11 @@ import type { RuntimeEvent } from '@maka/core/runtime-event'; import { modelMetadataIdsForProvider } from '@maka/core/model-metadata'; import { PROVIDER_REGISTRY } from '@maka/core/llm-connections'; import { thinkingVariantsForModel } from '@maka/core/model-thinking'; -import { buildProviderOptions, getAIModel } from '@maka/runtime/model-factory'; -import { z } from 'zod'; -import { routeApplyPatchTools } from '../apply-patch-profile.js'; +import { buildProviderOptions, getAIModel } from '../model-factory.js'; import { resolveModelRuntime } from '../model-runtime.js'; import { lowerModelTools } from '../model-adapter.js'; import { openAiCodexCompactionMessages } from '../openai-codex-history-compactor.js'; +import { openAiResponsesBaseUrl, openResponsesUrl } from '../provider-urls.js'; function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmConnection { return { @@ -24,17 +23,7 @@ function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmCo }; } -/** - * Every Responses wire is dialled through `createOpenAI(...).responses(...)` in - * `getAIModel`, whatever the adapter kind is — the native OpenAI provider is the - * only one that speaks it. Its provider-options namespace is `openai`, and the - * SDK reads no other one: the Responses model picks its namespace by asking - * whether its own provider name contains `azure`, and only that Azure case ever - * retries under `openai`. `parseProviderOptions` itself reads the one namespace - * it is handed and nothing else, so options filed under a compatible provider's - * own namespace are not dropped by a fallback that missed — they are never - * looked at. - */ +/** OpenAI's encrypted Responses dialect still reads the `openai` namespace. */ function openAiNamespace(options: Record): Record | undefined { const inner = options.openai; return typeof inner === 'object' && inner !== null @@ -53,7 +42,104 @@ describe('responses wire contract', () => { } }); - test('every Responses model asks for encrypted reasoning', () => { + test('normalizes the upstream Open Responses endpoint exactly once', () => { + assert.equal( + openResponsesUrl('https://api.deepseek.com/v1'), + 'https://api.deepseek.com/v1/responses', + ); + assert.equal( + openResponsesUrl('https://api.deepseek.com/v1/responses'), + 'https://api.deepseek.com/v1/responses', + ); + assert.equal(openResponsesUrl('https://relay.example/'), 'https://relay.example/responses'); + }); + + test('reduces an endpoint-form override to the base the native adapter expects', async () => { + assert.equal( + openAiResponsesBaseUrl('https://relay.example/v1/responses'), + 'https://relay.example/v1', + ); + assert.equal(openAiResponsesBaseUrl('https://relay.example/v1'), 'https://relay.example/v1'); + assert.equal(openAiResponsesBaseUrl('https://relay.example/'), 'https://relay.example/'); + + // Behavior level: the probe accepts the endpoint form; the native OpenAI + // adapter appends `/responses` internally, so the request must not land on + // `/responses/responses` (#2972). + const urls: string[] = []; + const fetch = (async (url: string | URL | Request, _init?: RequestInit) => { + urls.push(String(url)); + return Response.json({ + id: 'r', + object: 'response', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }) as unknown as typeof globalThis.fetch; + const connection = { + ...conn('openai-responses-compatible'), + baseUrl: 'https://relay.example/v1/responses', + }; + const model = getAIModel({ connection, apiKey: '[redacted]', modelId: 'relay-model', fetch }); + + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }], + }); + + assert.deepEqual(urls, ['https://relay.example/v1/responses']); + }); + + test('resolves only supported Responses adapter and replay pairings', () => { + const deepseek = resolveModelRuntime({ providerType: 'deepseek' }, 'deepseek-v4-flash'); + assert.deepEqual(deepseek.reasoningReplay, { + kind: 'responses', + contract: { adapter: 'open-responses', reasoningReplay: 'plaintext-content' }, + }); + + const xai = resolveModelRuntime({ providerType: 'xai' }, 'grok-4.5'); + assert.deepEqual(xai.reasoningReplay, { + kind: 'responses', + contract: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, + }); + + const relay = resolveModelRuntime( + { providerType: 'openai-responses-compatible' }, + 'relay-model', + ); + assert.deepEqual(relay.reasoningReplay, { + kind: 'responses', + contract: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, + }); + }); + + test('enables Responses only through an explicit supported contract', () => { + const configured = Object.entries(PROVIDER_REGISTRY).flatMap(([providerType, definition]) => { + const adapter = definition.runtimeAdapter; + return adapter.kind === 'openai-compatible' && adapter.responses + ? [{ providerType, contract: adapter.responses }] + : []; + }); + + assert.deepEqual(configured, [ + { + providerType: 'deepseek', + contract: { adapter: 'open-responses', reasoningReplay: 'plaintext-content' }, + }, + { + providerType: 'xai', + contract: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, + }, + { + providerType: 'xai-oauth', + contract: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, + }, + ]); + + const relay = PROVIDER_REGISTRY['openai-responses-compatible'].runtimeAdapter; + assert.equal(relay.kind, 'openai'); + }); + + test('every encrypted-content Responses contract asks for encrypted reasoning', () => { // `store: false` is not a privacy preference here, it is the switch that // makes the SDK add `include: ['reasoning.encrypted_content']` and drop // reasoning items that came back without one. Asking is the only way a @@ -68,13 +154,19 @@ describe('responses wire contract', () => { ...modelMetadataIdsForProvider(providerType), ]); for (const modelId of modelIds) { - let wire: string; + let runtime: ReturnType; try { - wire = resolveModelRuntime({ providerType }, modelId).wire; + runtime = resolveModelRuntime({ providerType }, modelId); } catch { continue; } - if (wire !== 'openai-responses') continue; + if ( + runtime.wire !== 'openai-responses' || + (runtime.reasoningReplay.kind === 'responses' && + runtime.reasoningReplay.contract.reasoningReplay === 'plaintext-content') + ) { + continue; + } // Sweep the declared levels and the unset case: `store` is a property // of the wire, not of a thinking choice, so a model reaches this branch // whether or not a level was picked. @@ -307,7 +399,48 @@ describe('responses wire request body', () => { }); }); - test('sends DeepSeek-compatible freeform apply_patch calls and plain-text results', async () => { + test('DeepSeek uses plaintext Responses options without asking for encrypted content', async () => { + const bodies: Record[] = []; + let headers: Headers | undefined; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as Record); + headers = new Headers(init?.headers); + return new Response( + JSON.stringify({ + id: 'r', + object: 'response', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }) as unknown as typeof globalThis.fetch; + + const connection = conn('deepseek'); + const model = getAIModel({ + connection, + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + for (const level of ['high', 'max'] as const) { + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + providerOptions: buildProviderOptions(connection, 'deepseek-v4-flash', level), + }); + } + + assert.equal(bodies[0]?.store, undefined); + assert.equal(bodies[0]?.include, undefined); + assert.equal((bodies[0]?.reasoning as { effort?: string } | undefined)?.effort, 'high'); + // The provider-native reasoningEffort passes `max` through verbatim; the + // old top-level enum downgrade (`xhigh`) would land on DeepSeek as high. + assert.equal((bodies[1]?.reasoning as { effort?: string } | undefined)?.effort, 'max'); + assert.equal(headers?.get('authorization'), 'Bearer test-key'); + }); + + test('replays plaintext reasoning before its function call and result', async () => { let body: Record | undefined; const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { body = JSON.parse(String(init?.body)); @@ -326,48 +459,19 @@ describe('responses wire request body', () => { modelId: 'deepseek-v4-flash', fetch, }); - const patch = '*** Begin Patch\n*** Delete File: old.txt\n*** End Patch'; - const runtime = resolveModelRuntime(connection, 'deepseek-v4-flash'); - const [routedTool] = routeApplyPatchTools( - [ - { - name: 'apply_patch', - description: 'Apply file changes', - parameters: z.object({}), - providerTool: { kind: 'openai-apply-patch' }, - impl: async () => ({ status: 'completed' }), - }, - ], - runtime.applyPatchProfile, - ); - assert.ok(routedTool); - assert.ok(routedTool.providerTool); - assert.equal((routedTool.parameters as z.ZodType).safeParse(patch).success, true); - assert.deepEqual( - await routedTool.toModelOutput?.({ - toolCallId: 'call-1', - input: patch, - output: { status: 'completed', output: 'Applied 1 file operation.' }, - }), - { type: 'text', value: 'Applied 1 file operation.' }, - ); - const tools = lowerModelTools({ - apply_patch: { - kind: 'provider', - providerTool: routedTool.providerTool, - }, - }); await model.doGenerate({ prompt: [ + { role: 'user', content: [{ type: 'text', text: 'Read package.json' }] }, { role: 'assistant', content: [ + { type: 'reasoning', text: 'I should inspect the requested file.' }, { type: 'tool-call', - toolCallId: 'call-1', - toolName: 'apply_patch', - input: patch, + toolCallId: 'call-read', + toolName: 'Read', + input: { path: 'package.json' }, }, ], }, @@ -376,67 +480,47 @@ describe('responses wire request body', () => { content: [ { type: 'tool-result', - toolCallId: 'call-1', - toolName: 'apply_patch', - output: { type: 'text', value: 'Applied 1 file operation.' }, + toolCallId: 'call-read', + toolName: 'Read', + output: { type: 'text', value: '{"name":"maka"}' }, }, ], }, ], - tools: [{ ...(tools.apply_patch as object), name: 'apply_patch' } as never], - providerOptions: { openai: { store: false } }, + tools: [ + { + type: 'function', + name: 'Read', + inputSchema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + additionalProperties: false, + }, + }, + ], }); - assert.deepEqual((body?.tools as unknown[] | undefined)?.[0], { - type: 'custom', - name: 'apply_patch', + const input = body?.input as Array> | undefined; + assert.deepEqual( + input?.map((item) => item.type), + ['message', 'reasoning', 'function_call', 'function_call_output'], + ); + assert.deepEqual(input?.[1], { + type: 'reasoning', + summary: [], + content: [{ type: 'reasoning_text', text: 'I should inspect the requested file.' }], }); - assert.deepEqual((body?.input as unknown[] | undefined)?.slice(-2), [ - { type: 'custom_tool_call', call_id: 'call-1', name: 'apply_patch', input: patch }, - { - type: 'custom_tool_call_output', - call_id: 'call-1', - output: 'Applied 1 file operation.', - }, - ]); - }); - - test('a non-OpenAI-named Responses model still asks for encrypted reasoning', async () => { - // The options shape alone does not prove the wire: the SDK only adds the - // include when it also believes the model reasons, and it decides that by - // parsing the model id. `deepseek-v4-flash` fails that parse, so this - // asserts the body the provider actually receives rather than the options - // we hand the SDK. Without `forceReasoning` the include silently vanishes - // while the options still look right. - let body: Record | undefined; - const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { - body = JSON.parse(String(init?.body)); - return new Response( - JSON.stringify({ - id: 'r', - object: 'response', - status: 'completed', - output: [], - usage: { input_tokens: 1, output_tokens: 1 }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - }) as unknown as typeof globalThis.fetch; - - const connection = conn('deepseek'); - const model = getAIModel({ - connection, - apiKey: 'test-key', - modelId: 'deepseek-v4-flash', - fetch, + assert.deepEqual(input?.[2], { + type: 'function_call', + call_id: 'call-read', + name: 'Read', + arguments: '{"path":"package.json"}', }); - await model.doGenerate({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], - providerOptions: buildProviderOptions(connection, 'deepseek-v4-flash', 'max'), + assert.deepEqual(input?.[3], { + type: 'function_call_output', + call_id: 'call-read', + output: '{"name":"maka"}', }); - - assert.equal(body?.store, false); - assert.deepEqual(body?.include, ['reasoning.encrypted_content']); - assert.equal((body?.reasoning as { effort?: string } | undefined)?.effort, 'max'); }); }); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 79d4193497..36bae361ad 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -138,6 +138,7 @@ import { type ModelStreamResult, type RepairableAiSdkToolCall, } from './model-adapter.js'; +import { buildProviderOptions } from './model-factory.js'; import { persistedOpenAiResponsesStepMessages } from './openai-responses-continuation.js'; import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js'; import { @@ -220,7 +221,6 @@ import { import { modelUsesNativeOpenAiResponses, resolveModelRuntime } from './model-runtime.js'; import { applyPatchReplayFactText, - freeformApplyPatchResultText, normalizeApplyPatchReplayInput, routeApplyPatchTools, type ApplyPatchProfile, @@ -900,16 +900,6 @@ function nativeApplyPatchFailureOutput(output: ToolResultOutput): ToolResultOutp }; } -function freeformApplyPatchOutput(output: ToolResultOutput): ToolResultOutput { - if (output.type === 'text' || output.type === 'error-text') return output; - const value = output.type === 'json' || output.type === 'error-json' ? output.value : undefined; - const record = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined; - const text = record ? freeformApplyPatchResultText(record) : freeformApplyPatchResultText(value); - return output.type === 'error-json' - ? { type: 'error-text', value: text } - : { type: 'text', value: text }; -} - const MAX_PROVIDER_ATTEMPTS_PER_STEP = 10; const MAX_IDLE_WATCHDOG_RETRIES_PER_STEP = 1; const MAX_INCOMPLETE_STREAM_RETRIES_PER_STEP = 1; @@ -1048,6 +1038,7 @@ export class AiSdkBackend implements AgentBackend { private readonly maxSteps: number | undefined; private readonly providerRetrySleep: (delayMs: number, signal: AbortSignal) => Promise; private readonly modelAdapter: ModelAdapter; + private readonly resolvedProviderOptions: Record; private readonly toolAvailabilityRuntime: ToolAvailabilityRuntime; private readonly applyPatchProfile: ApplyPatchProfile | null; @@ -1083,13 +1074,23 @@ export class AiSdkBackend implements AgentBackend { this.now = input.now ?? (() => Date.now()); this.maxSteps = input.maxSteps; this.providerRetrySleep = input.providerRetrySleep ?? sleepForProviderRetry; + // One resolved options value for every reader: the main call, the + // auxiliary memory-extraction call, and the request-shape diagnostics all + // describe the same request, so they must not disagree on what was sent. + this.resolvedProviderOptions = + input.providerOptions ?? + buildProviderOptions(input.connection, input.modelId, input.header.thinkingLevel); this.modelAdapter = new ModelAdapter({ sessionId: input.sessionId, connection: input.connection, apiKey: input.apiKey, modelId: input.modelId, modelFactory: input.modelFactory, - providerOptions: input.providerOptions, + // `input.providerOptions` is an override escape hatch: when set it owns + // the whole provider-options namespace (including reasoning effort), and + // the computed defaults are dropped entirely. Keep providerOptions the + // single seam — do not re-add a parallel reasoning channel here. + providerOptions: this.resolvedProviderOptions, newId: this.newId, now: this.now, ...(input.openAiResponsesTransportState @@ -1202,9 +1203,7 @@ export class AiSdkBackend implements AgentBackend { : {}), sourceTools: { ...scope.memorySourceTools }, sourceActiveTools: [...scope.memorySourceActiveTools], - ...(this.input.providerOptions - ? { sourceProviderOptions: structuredClone(this.input.providerOptions) } - : {}), + sourceProviderOptions: structuredClone(this.resolvedProviderOptions), ...(this.modelAdapter.maxOutputTokens() !== undefined ? { sourceMaxOutputTokens: this.modelAdapter.maxOutputTokens() } : {}), @@ -1985,7 +1984,7 @@ export class AiSdkBackend implements AgentBackend { connection: this.input.connection, modelId: this.input.modelId, systemPrompt, - providerOptions: this.input.providerOptions, + providerOptions: this.resolvedProviderOptions, providerTools, activeTools: active, priorMessages: priorReplay.messages, @@ -2049,7 +2048,7 @@ export class AiSdkBackend implements AgentBackend { connection: this.input.connection, modelId: this.input.modelId, systemPrompt, - providerOptions: this.input.providerOptions, + providerOptions: this.resolvedProviderOptions, providerTools, activeTools: activeToolsForStep ?? plan.activeTools, priorMessages: stepMessages, @@ -3831,9 +3830,22 @@ export class AiSdkBackend implements AgentBackend { } if (!this.canReplayProviderNative(plan)) { + // Degrade per item, not per plan: an unsupported provider-executed pair + // must not cost unrelated client tool history (#2972). Thinking items + // stay in the plan; materializeRuntimeReplayPlan degrades unsupported + // reasoning per item via reasoningReplay. + const degradedPlan = this.dropUnsupportedReplayItems(plan); return { status: 'ready', - messages: await materializeReplayFallback(), + messages: + degradedPlan.items.length > 0 || hasProviderHistoryCompactCheckpoint + ? await this.materializeRuntimeReplayPlan( + degradedPlan, + scope.imageBudget, + undefined, + projectedHistoryCompactCheckpoint, + ) + : await materializeReplayFallback(), gate: input.continuation ? 'runtime_replay_text_only' : 'runtime_replay_unsupported_semantics', @@ -3865,11 +3877,41 @@ export class AiSdkBackend implements AgentBackend { for (const item of plan.items) { if (item.kind === 'tool_call' && !support.toolCalls) return false; if (item.kind === 'tool_result' && !support.toolResults) return false; + if ( + (item.kind === 'tool_call' || item.kind === 'tool_result') && + item.providerExecuted === true && + !support.providerExecutedTools + ) { + return false; + } if (item.kind === 'thinking' && item.signature && !support.signedThinking) return false; } return true; } + /** + * Per-item counterpart to {@link canReplayProviderNative}: drop only the + * items the adapter cannot represent so one unsupported provider-executed + * pair does not cost unrelated client tool history (#2972). Call and result + * items fall together — a call without its result is a dangling wire item, + * and provider-executed pairs are flagged on both items by the plan. + */ + private dropUnsupportedReplayItems( + plan: RuntimeEventModelReplayPlan, + ): RuntimeEventModelReplayPlan { + const support = this.modelAdapter.runtimeEventReplaySupport(); + return { + ...plan, + items: plan.items.filter((item) => { + if (item.kind === 'tool_call' || item.kind === 'tool_result') { + if (!support.toolCalls || !support.toolResults) return false; + if (item.providerExecuted === true && !support.providerExecutedTools) return false; + } + return true; + }), + }; + } + /** * Materialize a replay plan into provider messages, grouping each assistant * step's reasoning + text + tool calls into ONE assistant message (Anthropic @@ -3930,7 +3972,16 @@ export class AiSdkBackend implements AgentBackend { } : undefined; } - if (replaySupport.openAiResponsesEncryptedThinking) { + 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)) { const { itemId, reasoningEncryptedContent } = openai as { @@ -3988,9 +4039,6 @@ export class AiSdkBackend implements AgentBackend { `runtime-event:${result.eventId}:tool-result`, )); if (toolName !== 'apply_patch') return output; - if (this.applyPatchProfile?.kind === 'codex-v4a-freeform') { - return freeformApplyPatchOutput(output); - } return result.isError ? nativeApplyPatchFailureOutput(output) : output; }; const pushClientToolResults = async (calls: readonly ToolCallItem[]) => { diff --git a/packages/runtime/src/apply-patch-profile.ts b/packages/runtime/src/apply-patch-profile.ts index 80dca16c30..5a906bcd18 100644 --- a/packages/runtime/src/apply-patch-profile.ts +++ b/packages/runtime/src/apply-patch-profile.ts @@ -1,15 +1,11 @@ import type { ApplyPatchProtocol } from '@maka/core/llm-connections'; -import { deepSeekModelSupportsResponses } from '@maka/core/model-metadata'; -import { z } from 'zod'; -import { parseCodexV4aPatch, serializeCodexV4aOperation } from './codex-v4a-patch.js'; +import { parseCodexV4aPatch } from './codex-v4a-patch.js'; import type { ApplyPatchOperation } from './filesystem-executor.js'; import type { ModelRuntimeWire } from './model-runtime.js'; import { openAiModelSupportsApplyPatch } from './openai-apply-patch.js'; import type { MakaTool } from './tool-runtime.js'; -export type ApplyPatchProfile = - | { readonly kind: 'openai-structured' } - | { readonly kind: 'codex-v4a-freeform' }; +export type ApplyPatchProfile = { readonly kind: 'openai-structured' }; export interface ApplyPatchProfileRuntime { readonly wire: ModelRuntimeWire; @@ -26,9 +22,6 @@ export function resolveApplyPatchProfile( if (runtime.applyPatchProtocol === 'openai-structured' && openAiModelSupportsApplyPatch(id)) { return { kind: 'openai-structured' }; } - if (runtime.applyPatchProtocol === 'codex-v4a-freeform' && deepSeekModelSupportsResponses(id)) { - return { kind: 'codex-v4a-freeform' }; - } return null; } @@ -41,44 +34,19 @@ export function routeApplyPatchTools( if (!applyPatchTool) return [...tools]; if (!profile) return tools.filter((tool) => tool !== applyPatchTool); - return tools - .filter((tool) => tool.name !== 'Write' && tool.name !== 'Edit') - .map((tool) => { - if (tool !== applyPatchTool || profile.kind !== 'codex-v4a-freeform') return tool; - return { - ...tool, - parameters: z.string(), - providerTool: { kind: 'openai-custom-apply-patch' as const }, - toModelOutput: ({ output }) => ({ - type: 'text' as const, - value: freeformApplyPatchResultText(output), - }), - }; - }); -} - -export function freeformApplyPatchResultText(output: unknown): string { - if (typeof output === 'string') return output; - if (output && typeof output === 'object') { - const record = output as { output?: unknown; error?: unknown }; - if (typeof record.output === 'string') return record.output; - if (typeof record.error === 'string') return record.error; - } - return 'ApplyPatch completed.'; + return tools.filter((tool) => tool.name !== 'Write' && tool.name !== 'Edit'); } -/** Convert historical calls when a session switches between the two patch contracts. */ +/** Convert historical freeform calls for a structured target, or reject an undeclared target. */ export function normalizeApplyPatchReplayInput( profile: ApplyPatchProfile | null, toolCallId: string, input: unknown, ): unknown | null { - if (!profile) return input; - if (profile.kind === 'codex-v4a-freeform') { - if (typeof input === 'string') return input; - const operation = structuredApplyPatchOperation(input); - return operation ? serializeCodexV4aOperation(operation) : null; - } + // A missing profile means the target request does not advertise ApplyPatch. + // Returning the historical input would serialize a call to an undeclared + // tool; route it through the durable-fact downgrade instead. + if (!profile) return null; if (typeof input !== 'string') return input; try { const operations = parseCodexV4aPatch(input); @@ -94,12 +62,17 @@ export function applyPatchReplayFactText( output: unknown, isError: boolean, ): string | null { - if (typeof input !== 'string') return null; let operations: ApplyPatchOperation[]; - try { - operations = parseCodexV4aPatch(input); - } catch { - return null; + if (typeof input === 'string') { + try { + operations = parseCodexV4aPatch(input); + } catch { + return null; + } + } else { + const operation = structuredApplyPatchOperation(input); + if (!operation) return null; + operations = [operation]; } const recorded = recordedAppliedOperations(output); const applied = recorded ?? (isError ? [] : operations.map(operationFact)); diff --git a/packages/runtime/src/codex-v4a-patch.ts b/packages/runtime/src/codex-v4a-patch.ts index 2f611dffd9..b32b677527 100644 --- a/packages/runtime/src/codex-v4a-patch.ts +++ b/packages/runtime/src/codex-v4a-patch.ts @@ -72,14 +72,3 @@ export function parseCodexV4aPatch(input: string): ApplyPatchOperation[] { } return operations; } - -export function serializeCodexV4aOperation(operation: ApplyPatchOperation): string { - const header = - operation.type === 'create_file' - ? `*** Add File: ${operation.path}` - : operation.type === 'delete_file' - ? `*** Delete File: ${operation.path}` - : `*** Update File: ${operation.path}`; - const body = operation.type === 'delete_file' ? '' : `\n${operation.diff.replace(/\n$/, '')}`; - return `*** Begin Patch\n${header}${body}\n*** End Patch`; -} diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 01b5782f7f..c3a24291db 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -64,10 +64,7 @@ import { OPENAI_RESPONSES_LANE_HEADER, type OpenAiResponsesTransportState, } from './openai-responses-websocket.js'; -import { - codexV4aApplyPatchProviderTool, - openAiApplyPatchProviderTool, -} from './openai-apply-patch.js'; +import { openAiApplyPatchProviderTool } from './openai-apply-patch.js'; /** * Build an ai-sdk LanguageModel from a single input object. @@ -179,14 +176,24 @@ export class ModelAdapter { return { toolCalls: true, toolResults: true, + // Verified against @ai-sdk/open-responses@2.0.28: replay now preserves + // item order and IDs, but a provider-executed result embedded in the + // assistant message (Maka's provider-tool chronology) is still dropped, + // leaving a dangling function_call on the wire. Fail closed until the + // upstream extension seam (vercel/ai#18899) can round-trip the pair. + providerExecutedTools: + this.runtime.reasoningReplay.kind !== 'responses' || + this.runtime.reasoningReplay.contract.adapter !== 'open-responses', signedThinking: this.runtime.reasoningReplay.kind === 'anthropic-signed', // openai-compatible transports replay stored reasoning unconditionally: // DeepSeek-style endpoints 400 tool calls whose history lacks it, and // relays that don't need the field ignore it. Reasoning is still // recorded to the event log and rendered regardless. unsignedThinking: this.runtime.reasoningReplay.kind === 'openai-chat-plaintext', - openAiResponsesEncryptedThinking: - this.runtime.reasoningReplay.kind === 'openai-responses-encrypted', + responsesReasoning: + this.runtime.reasoningReplay.kind === 'responses' + ? this.runtime.reasoningReplay.contract.reasoningReplay + : 'none', }; } @@ -633,9 +640,10 @@ function fixedAnthropicThinkingBudget( export interface ModelAdapterRuntimeEventReplaySupport { toolCalls: boolean; toolResults: boolean; + providerExecutedTools: boolean; signedThinking: boolean; unsignedThinking: boolean; - openAiResponsesEncryptedThinking: boolean; + responsesReasoning: 'none' | 'encrypted-content' | 'plaintext-content'; } /** @@ -909,8 +917,6 @@ function compileProviderTool( switch (tool.kind) { case 'openai-apply-patch': return openAiApplyPatchProviderTool; - case 'openai-custom-apply-patch': - return codexV4aApplyPatchProviderTool; case 'openai-web-search': return openai.tools.webSearch({ ...(tool.searchContextSize ? { searchContextSize: tool.searchContextSize } : {}), diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 65e3d44dba..f6dc871cfb 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -1,6 +1,7 @@ import { createAnthropic } from '@ai-sdk/anthropic'; import { createCohere } from '@ai-sdk/cohere'; import { createGoogle } from '@ai-sdk/google'; +import { createOpenResponses } from '@ai-sdk/open-responses'; import { createOpenAI } from '@ai-sdk/openai'; import { createOpenAICompatible, type MetadataExtractor } from '@ai-sdk/openai-compatible'; import { @@ -25,9 +26,13 @@ import { createOpenAiChatReasoningTransportState, type OpenAiChatReasoningTransportState, } from './openai-chat-reasoning-transport.js'; -import { createOpenAiResponsesPlaintextReasoningTransport } from './openai-responses-plaintext-reasoning-transport.js'; import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js'; -import { anthropicV1BaseUrl, googleV1BetaBaseUrl } from './provider-urls.js'; +import { + anthropicV1BaseUrl, + googleV1BetaBaseUrl, + openAiResponsesBaseUrl, + openResponsesUrl, +} from './provider-urls.js'; import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime.js'; import { claudeSubscriptionHeaders, openAiCodexHeaders } from './subscription-auth.js'; import { createRequestCustomizationFetch } from './request-customization-fetch.js'; @@ -119,7 +124,9 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { case 'openai': { const openai = createOpenAI({ apiKey, - baseURL, + // The native adapter appends `/responses`; reduce endpoint-form + // overrides back to their base so probe-approved relay URLs work. + baseURL: wire === 'openai-responses' && baseURL ? openAiResponsesBaseUrl(baseURL) : baseURL, fetch: !hasRequestCustomization && openAiResponsesTransportState ? openAiResponsesTransportState.wrapFetch(requestFetch) @@ -145,19 +152,23 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { ); } if (wire === 'openai-responses') { - // Measured against the live API rather than inferred from the wire: - // DeepSeek streams reasoning as `response.reasoning_text.delta`, which - // the SDK never reads, so its reasoning parts arrive empty. Keep this - // to the provider we have evidence for — a Responses wire says nothing - // about which reasoning shape a provider speaks, and the others - // reaching here have not been measured. - const speaksPlaintextReasoning = connection.providerType === 'deepseek'; + if (reasoningReplay.kind !== 'responses') { + throw new Error('Responses wire requires a Responses continuation contract'); + } + if (reasoningReplay.contract.adapter === 'open-responses') { + return createOpenResponses({ + name: openAiCompatibleProviderName(adapter, connection), + apiKey, + url: openResponsesUrl(baseURL), + fetch: requestFetch, + })(modelId); + } return createOpenAI({ apiKey, - baseURL, - fetch: speaksPlaintextReasoning - ? createOpenAiResponsesPlaintextReasoningTransport(requestFetch) - : requestFetch, + // Endpoint-form overrides (`…/responses`) probe successfully; the + // native adapter appends `/responses` itself, so pass the base. + baseURL: baseURL ? openAiResponsesBaseUrl(baseURL) : baseURL, + fetch: requestFetch, }).responses(modelId); } if (reasoningReplay.kind !== 'openai-chat-plaintext') { @@ -509,39 +520,35 @@ function buildFamilyWire( level: ThinkingLevel | undefined, thinkingOptions: ThinkingOptions | undefined, ): SharedV4ProviderOptions { - const { adapter, wire } = resolveModelRuntime(connection, modelId); + const { adapter, wire, reasoningReplay } = resolveModelRuntime(connection, modelId); const reasoningEffort = level ? (level === 'off' ? 'none' : level) : undefined; - // Whatever the adapter kind, a Responses wire is dialled through the native - // OpenAI provider (`getAIModel`), so `openai` is the only provider-options - // namespace the SDK will read: an openai-compatible provider's own namespace - // is silently dropped there — including the effort, so a model asking for - // `max` sent no reasoning parameter at all. - // - // `store: false` is not a storage preference, it is the switch that makes the - // SDK ask for `include: ['reasoning.encrypted_content']` and, on the request - // side, drop any reasoning item that came back without one. Both halves are - // what we want here: a provider that speaks the encrypted-content contract - // gets a replayable chain, and one that does not stops shipping empty husks - // it could never replay. Which of the two a given provider is remains its own - // business, and this says nothing about how it carries reasoning otherwise — - // DeepSeek returns plaintext in `content[].reasoning_text` and consumes it in - // the same shape, a dialect the SDK neither reads nor writes. Bridging that - // is a transport's job, not this function's. Either way `store` is a property - // of the wire rather than of a thinking choice, so it holds whether or not a - // level was picked. - // - // The include is gated on the SDK also believing this is a reasoning model, - // and it decides that by parsing the model id for an OpenAI naming scheme — - // `deepseek-v4-flash` and `grok-4.5` fail that test however they are served. - // Our own declared thinking variants are the authority on that question, so - // say so with `forceReasoning` rather than letting a name decide. + // Provider selection and reasoning continuation are independent. The OpenAI + // provider reads its provider-options namespace; the Open Responses provider + // consumes a provider-native reasoningEffort through the same namespace, + // keyed by the provider name getAIModel passes to createOpenResponses. if (wire === 'openai-responses') { + if (reasoningReplay.kind !== 'responses') { + throw new Error('Responses wire requires a Responses continuation contract'); + } // Connection-aware: a relay model's declared variants count too. const reasons = thinkingVariantsForConnection(connection, modelId).length > 0; + if (reasoningReplay.contract.adapter === 'open-responses') { + // @ai-sdk/open-responses@2.0.28 passes a provider-native reasoningEffort + // through verbatim, ahead of the cross-provider top-level `reasoning` + // enum that cannot express DeepSeek's `max` (whose documented mapping + // sends `xhigh` to high, not max). The SDK resolves providerOptions + // under the raw provider `name` — no camelCase alias, unlike + // openai-compatible — so key by the same name getAIModel passes. + return reasoningEffort + ? { [openAiCompatibleProviderName(adapter, connection)]: { reasoningEffort } } + : {}; + } return { openai: { store: false, - ...(reasons ? { forceReasoning: true } : {}), + ...(reasons || reasoningReplay.contract.reasoningReplay === 'encrypted-content' + ? { forceReasoning: true } + : {}), ...(reasoningEffort ? { reasoningEffort } : {}), }, }; diff --git a/packages/runtime/src/model-runtime.ts b/packages/runtime/src/model-runtime.ts index 94a60ba7f8..6929c8fb13 100644 --- a/packages/runtime/src/model-runtime.ts +++ b/packages/runtime/src/model-runtime.ts @@ -2,6 +2,7 @@ import { PROVIDER_DEFAULTS, effectiveBaseUrl, type ModelInfo, + type ProviderResponsesContract, type ProviderRuntimeAdapter, type ProviderType, } from '@maka/core/llm-connections'; @@ -19,7 +20,7 @@ export type ReasoningReplayContract = | { kind: 'none' } | { kind: 'anthropic-signed' } | { kind: 'openai-chat-plaintext'; requestField: 'observed' | 'reasoning' } - | { kind: 'openai-responses-encrypted' }; + | { kind: 'responses'; contract: ProviderResponsesContract }; export interface ResolvedModelRuntime { adapter: ProviderRuntimeAdapter; @@ -90,7 +91,10 @@ export function resolveModelRuntime( wire, reasoningReplay: reasoningReplayContract(adapter, wire), applyPatchProfile: resolveApplyPatchProfile( - { wire, applyPatchProtocol: adapter.applyPatchProtocol }, + { + wire, + applyPatchProtocol: adapter.applyPatchProtocol, + }, modelId, ), }; @@ -139,7 +143,7 @@ function resolveModelRuntimeWire( ? 'openai-responses' : 'openai-chat'; case 'openai-compatible': - return adapter.supportsOpenAiResponses === true && + return adapter.responses !== undefined && (apiProtocol ?? openAiAdapterApiProtocol(modelId, providerType)) === 'openai-responses' ? 'openai-responses' : 'openai-chat'; @@ -158,11 +162,7 @@ function reasoningReplayContract( case 'anthropic-messages': return { kind: 'anthropic-signed' }; case 'openai-responses': - // The native OpenAI serializer can replay only provider-issued encrypted - // reasoning when store=false. Open Responses plaintext reasoning is read - // by a separate response transport, but has no request codec here yet; - // @ai-sdk/open-responses unlocks an open-responses-plaintext sibling. - return { kind: 'openai-responses-encrypted' }; + return { kind: 'responses', contract: responsesContract(adapter) }; case 'openai-chat': return adapter.kind === 'openai-compatible' ? { @@ -177,6 +177,11 @@ function reasoningReplayContract( } } +function responsesContract(adapter: ProviderRuntimeAdapter): ProviderResponsesContract { + if (adapter.kind === 'openai-compatible' && adapter.responses) return adapter.responses; + return { adapter: 'openai', reasoningReplay: 'encrypted-content' }; +} + function kimiOpenAiBaseUrl(baseUrl: string): string { return `${baseUrl.replace(/\/+$/, '').replace(/\/v1$/i, '')}/v1`; } diff --git a/packages/runtime/src/openai-apply-patch.ts b/packages/runtime/src/openai-apply-patch.ts index ced8d5bcb1..f95ce52f85 100644 --- a/packages/runtime/src/openai-apply-patch.ts +++ b/packages/runtime/src/openai-apply-patch.ts @@ -1,7 +1,6 @@ import { openai } from '@ai-sdk/openai'; export const openAiApplyPatchProviderTool = openai.tools.applyPatch({}); -export const codexV4aApplyPatchProviderTool = openai.tools.customTool({}); const inputSchema = openAiApplyPatchProviderTool.inputSchema; export const openAiApplyPatchInputSchema = typeof inputSchema === 'function' ? inputSchema() : inputSchema; diff --git a/packages/runtime/src/openai-responses-plaintext-reasoning-transport.ts b/packages/runtime/src/openai-responses-plaintext-reasoning-transport.ts deleted file mode 100644 index 0818705e24..0000000000 --- a/packages/runtime/src/openai-responses-plaintext-reasoning-transport.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Responses providers disagree about where reasoning text lives. - * - * The Open Responses shape carries it as `content[].reasoning_text`, streamed - * as `response.reasoning_text.delta`. OpenAI's own extension carries it as - * `summary[].summary_text`, streamed as `response.reasoning_summary_text.delta`, - * and `@ai-sdk/openai` — the only provider that speaks this wire for us — reads - * that one alone. Against a provider using the standard shape it produces a - * reasoning part that opens and closes around nothing: `output_item.added` and - * `output_item.done` already name a reasoning item, so start and end arrive, - * while every delta lands on a field nobody reads. That is where the empty - * reasoning in stored sessions comes from. - * - * This translates the response side only. Nothing here changes what we send, - * so it cannot alter a request the provider already accepts. - * - * The two channels differ in how much they can defer to the provider. A whole - * JSON body shows both fields at once, so that path fills a gap and nothing - * more: a summary the provider populated itself is left alone. A stream is read - * one line at a time and a plaintext delta carries no evidence about what some - * later summary delta will say, so that path translates unconditionally. A - * provider that streamed both shapes at the same index would end up with the - * two concatenated. None does today — DeepSeek, the only one measured, streams - * plaintext alone — and buffering a whole reasoning item to find out would cost - * the streaming that is the point of the wire. - */ -import { responseWithBody } from './http-response.js'; - -const PLAINTEXT_DELTA = 'response.reasoning_text.delta'; -const SUMMARY_DELTA = 'response.reasoning_summary_text.delta'; - -export function createOpenAiResponsesPlaintextReasoningTransport( - fetchImpl: typeof globalThis.fetch = globalThis.fetch, -): typeof globalThis.fetch { - return async (input, init) => translateResponse(await fetchImpl(input, init)); -} - -function translateResponse(response: Response): Response { - if (!response.ok || !response.body) return response; - const contentType = response.headers.get('content-type') ?? ''; - if (contentType.includes('text/event-stream')) { - return responseWithBody(response, response.body.pipeThrough(translateEventStream())); - } - if (!contentType.includes('application/json')) return response; - return responseWithBody( - response, - new ReadableStream({ - async start(controller) { - const body = await response.text(); - controller.enqueue(new TextEncoder().encode(translateJsonBody(body))); - controller.close(); - }, - }), - ); -} - -/** - * SSE frames are newline-delimited but arrive on arbitrary chunk boundaries, so - * the tail of a chunk is held back until its line terminator shows up. Every - * line that is not a reasoning-text delta is passed through byte for byte. - */ -function translateEventStream(): TransformStream { - const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - let pending = ''; - return new TransformStream({ - transform(chunk, controller) { - pending += decoder.decode(chunk, { stream: true }); - const lines = pending.split('\n'); - pending = lines.pop() ?? ''; - for (const line of lines) { - controller.enqueue(encoder.encode(`${translateEventLine(line)}\n`)); - } - }, - flush(controller) { - // Bytes the decoder is still holding belong to a character split across - // the last chunk boundary; without this final decode they are dropped. - pending += decoder.decode(); - if (pending) controller.enqueue(encoder.encode(translateEventLine(pending))); - }, - }); -} - -function translateEventLine(line: string): string { - if (!line.startsWith('data:')) return line; - const payload = line.slice('data:'.length).trim(); - if (!payload || payload === '[DONE]') return line; - let event: unknown; - try { - event = JSON.parse(payload); - } catch { - return line; - } - if (!isRecord(event) || event.type !== PLAINTEXT_DELTA) return line; - // The SDK keys an in-flight reasoning part by `${item_id}:${summary_index}`, - // and the part it opened on `output_item.added` is index 0. The standard - // shape indexes the same position as `content_index`, so carrying it across - // keeps multiple reasoning items — and multiple parts within one — apart. - const { content_index: contentIndex, ...rest } = event; - return `data: ${JSON.stringify({ - ...rest, - type: SUMMARY_DELTA, - summary_index: typeof contentIndex === 'number' ? contentIndex : 0, - })}`; -} - -/** - * The non-streaming path reads `summary[]` only, so a reasoning item arrives - * with an empty string in it. Only the top-level output items are touched: - * a `reasoning_text` appearing anywhere else — inside tool arguments, say — is - * not a reasoning item and is left alone. - */ -function translateJsonBody(body: string): string { - let payload: unknown; - try { - payload = JSON.parse(body); - } catch { - return body; - } - if (!isRecord(payload) || !Array.isArray(payload.output)) return body; - let changed = false; - const output = payload.output.map((item) => { - if (!isRecord(item) || item.type !== 'reasoning') return item; - if (Array.isArray(item.summary) && item.summary.length > 0) return item; - if (!Array.isArray(item.content)) return item; - const summary = item.content.flatMap((part) => - isRecord(part) && part.type === 'reasoning_text' && typeof part.text === 'string' - ? [{ type: 'summary_text', text: part.text }] - : [], - ); - if (summary.length === 0) return item; - changed = true; - return { ...item, summary }; - }); - return changed ? JSON.stringify({ ...payload, output }) : body; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/packages/runtime/src/provider-urls.ts b/packages/runtime/src/provider-urls.ts index 8a79695616..ab0074a91b 100644 --- a/packages/runtime/src/provider-urls.ts +++ b/packages/runtime/src/provider-urls.ts @@ -36,6 +36,26 @@ export function googleApiUrl(baseUrl: string, path: string, apiKey: string): str return `${googleV1BetaBaseUrl(baseUrl)}${cleanPath}?key=${encodeURIComponent(apiKey)}`; } +/** Normalize an Open Responses endpoint without assuming a `/v1` prefix. */ +export function openResponsesUrl(baseUrl: string): string { + const url = new URL(baseUrl); + const basePath = url.pathname.replace(/\/+$/, '').replace(/\/responses$/i, ''); + url.pathname = `${basePath}/responses`; + return url.toString(); +} + +/** + * Inverse of {@link openResponsesUrl} for the native OpenAI adapter: it + * appends `/responses` internally, so an endpoint-form override + * (`…/v1/responses`, which the probe accepts) must be reduced back to its + * base or the model request lands on `/responses/responses` (#2972). + */ +export function openAiResponsesBaseUrl(baseUrl: string): string { + const url = new URL(baseUrl); + url.pathname = url.pathname.replace(/\/+$/, '').replace(/\/responses$/i, ''); + return url.toString(); +} + function stripTrailing(u: string): string { return u.replace(/\/+$/, ''); } diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index 7558261369..723e347962 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -5,7 +5,7 @@ import { type ConnectionTestResult, type LlmConnection, } from '@maka/core/llm-connections'; -import { anthropicV1Url, googleApiUrl } from './provider-urls.js'; +import { anthropicV1Url, googleApiUrl, openResponsesUrl } from './provider-urls.js'; import { resolveModelRuntime } from './model-runtime.js'; import { claudeSubscriptionHeaders } from './subscription-auth.js'; import { fetchGitHubCopilotModels } from './model-fetcher.js'; @@ -238,7 +238,7 @@ async function probeOpenAIResponses( t0: number, fetchFn: ConnectionEffectFetch | undefined, ): Promise { - const r = await fetchForConnectionEffect(fetchFn, `${stripTrailing(baseUrl)}/responses`, { + const r = await fetchForConnectionEffect(fetchFn, openResponsesUrl(baseUrl), { method: 'POST', headers: { authorization: `Bearer ${apiKey}`, diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 23b4e35569..d74b18aa15 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -129,11 +129,7 @@ export interface MakaTool

{ * client-executed tools such as ApplyPatch still settle through ToolRuntime. */ providerTool?: { - readonly kind: - | 'openai-apply-patch' - | 'openai-custom-apply-patch' - | 'openai-web-search' - | 'anthropic-web-search-20250305'; + readonly kind: 'openai-apply-patch' | 'openai-web-search' | 'anthropic-web-search-20250305'; readonly searchContextSize?: 'low' | 'medium' | 'high'; readonly maxUses?: number; };