From 4cdde6adb756836ae95a5c0266d9ad4c08139147 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 13 Aug 2026 23:22:21 +0800 Subject: [PATCH 01/12] fix(runtime): route plaintext Responses through its dialect --- package-lock.json | 17 + packages/core/src/provider-registry.ts | 3 + packages/runtime/package.json | 1 + packages/runtime/src/ai-sdk-backend.ts | 10 +- packages/runtime/src/model-adapter.ts | 10 +- packages/runtime/src/model-factory.ts | 62 +- packages/runtime/src/model-runtime.ts | 11 +- .../src/open-responses-plaintext-model.ts | 596 ++++++++++++++++++ ...responses-plaintext-reasoning-transport.ts | 140 ---- 9 files changed, 664 insertions(+), 186 deletions(-) create mode 100644 packages/runtime/src/open-responses-plaintext-model.ts delete mode 100644 packages/runtime/src/openai-responses-plaintext-reasoning-transport.ts diff --git a/package-lock.json b/package-lock.json index 273c2001f5..efc9f3fbf1 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.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/open-responses/-/open-responses-2.0.27.tgz", + "integrity": "sha512-1SUYWyJrtmYXokeeoLdNNWdacUOVtO6eXInCj2imIB59fcOXGuUdoHyZ2oF0TGZ1qCHfzoZ0f1elpTMHoXAtdA==", + "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.27", "@ai-sdk/openai": "4.0.42", "@ai-sdk/openai-compatible": "3.0.30", "@larksuiteoapi/node-sdk": "1.72.0", diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index faa5f5e3fb..09d518e388 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -31,6 +31,8 @@ type ProviderRuntimeAdapterDefinition = includeUsage?: boolean; requireBaseUrl?: boolean; supportsOpenAiResponses?: true; + /** Select the standard plaintext Open Responses dialect instead of OpenAI's extension. */ + responsesDialect?: 'open-responses'; replayAssistantReasoningAs?: 'reasoning'; replayAssistantReasoningDetails?: true; }; @@ -837,6 +839,7 @@ const providerRegistry = { kind: 'openai-compatible', name: 'provider', supportsOpenAiResponses: true, + responsesDialect: 'open-responses', applyPatchProtocol: 'codex-v4a-freeform', }, modelDiscovery: { kind: 'protocol' }, diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 527ceb5b66..202a6f5e0b 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.27", "@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/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 79d4193497..dc61daa1d8 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -3930,7 +3930,15 @@ export class AiSdkBackend implements AgentBackend { } : undefined; } - if (replaySupport.openAiResponsesEncryptedThinking) { + if (replaySupport.responsesThinking === 'open-responses-plaintext') { + return { + part: { + type: 'reasoning' as const, + text: item.text, + }, + }; + } + if (replaySupport.responsesThinking === 'openai-encrypted') { const openai = item.providerOptions?.openai; if (openai && typeof openai === 'object' && !Array.isArray(openai)) { const { itemId, reasoningEncryptedContent } = openai as { diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 01b5782f7f..d9e254d025 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -185,8 +185,12 @@ export class ModelAdapter { // 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', + responsesThinking: + this.runtime.reasoningReplay.kind === 'openai-responses-encrypted' + ? 'openai-encrypted' + : this.runtime.reasoningReplay.kind === 'open-responses-plaintext' + ? 'open-responses-plaintext' + : 'none', }; } @@ -635,7 +639,7 @@ export interface ModelAdapterRuntimeEventReplaySupport { toolResults: boolean; signedThinking: boolean; unsignedThinking: boolean; - openAiResponsesEncryptedThinking: boolean; + responsesThinking: 'none' | 'openai-encrypted' | 'open-responses-plaintext'; } /** diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 65e3d44dba..4fcda4061b 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -25,7 +25,7 @@ import { createOpenAiChatReasoningTransportState, type OpenAiChatReasoningTransportState, } from './openai-chat-reasoning-transport.js'; -import { createOpenAiResponsesPlaintextReasoningTransport } from './openai-responses-plaintext-reasoning-transport.js'; +import { createPlaintextOpenResponsesModel } from './open-responses-plaintext-model.js'; import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js'; import { anthropicV1BaseUrl, googleV1BetaBaseUrl } from './provider-urls.js'; import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime.js'; @@ -145,19 +145,19 @@ 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 === 'open-responses-plaintext') { + return createPlaintextOpenResponsesModel({ + providerName: openAiCompatibleProviderName(adapter, connection), + apiKey, + baseUrl: baseURL, + modelId, + fetch: requestFetch, + }); + } return createOpenAI({ apiKey, baseURL, - fetch: speaksPlaintextReasoning - ? createOpenAiResponsesPlaintextReasoningTransport(requestFetch) - : requestFetch, + fetch: requestFetch, }).responses(modelId); } if (reasoningReplay.kind !== 'openai-chat-plaintext') { @@ -509,35 +509,25 @@ 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. + // A Responses wire still has two distinct replay contracts. OpenAI's native + // dialect uses encrypted content and therefore reads `openai` options; + // `store: false` asks the SDK to include that replay token. Open Responses + // carries plaintext reasoning items and reads the compatible provider's own + // namespace. `getAIModel` selects the matching codec from the same resolved + // runtime, so a provider's effort cannot silently land in the wrong one. if (wire === 'openai-responses') { // Connection-aware: a relay model's declared variants count too. const reasons = thinkingVariantsForConnection(connection, modelId).length > 0; + if (reasoningReplay.kind === 'open-responses-plaintext') { + if (!reasoningEffort) return {}; + return { + [openAiCompatibleProviderOptionsKey(adapter, connection)]: { + reasoningEffort, + }, + }; + } return { openai: { store: false, diff --git a/packages/runtime/src/model-runtime.ts b/packages/runtime/src/model-runtime.ts index 94a60ba7f8..a98fd35de6 100644 --- a/packages/runtime/src/model-runtime.ts +++ b/packages/runtime/src/model-runtime.ts @@ -19,7 +19,8 @@ export type ReasoningReplayContract = | { kind: 'none' } | { kind: 'anthropic-signed' } | { kind: 'openai-chat-plaintext'; requestField: 'observed' | 'reasoning' } - | { kind: 'openai-responses-encrypted' }; + | { kind: 'openai-responses-encrypted' } + | { kind: 'open-responses-plaintext' }; export interface ResolvedModelRuntime { adapter: ProviderRuntimeAdapter; @@ -158,11 +159,9 @@ 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 adapter.kind === 'openai-compatible' && adapter.responsesDialect === 'open-responses' + ? { kind: 'open-responses-plaintext' } + : { kind: 'openai-responses-encrypted' }; case 'openai-chat': return adapter.kind === 'openai-compatible' ? { diff --git a/packages/runtime/src/open-responses-plaintext-model.ts b/packages/runtime/src/open-responses-plaintext-model.ts new file mode 100644 index 0000000000..fdca003575 --- /dev/null +++ b/packages/runtime/src/open-responses-plaintext-model.ts @@ -0,0 +1,596 @@ +import { createOpenResponses } from '@ai-sdk/open-responses'; +import { + isJSONValue, + type JSONObject, + LanguageModelV4, + LanguageModelV4CallOptions, + LanguageModelV4Content, + LanguageModelV4GenerateResult, + LanguageModelV4Message, + LanguageModelV4StreamPart, + LanguageModelV4StreamResult, + SharedV4ProviderOptions, +} from '@ai-sdk/provider'; +import { responseWithBody } from './http-response.js'; + +const OPENAI_CUSTOM_TOOL_ID = 'openai.custom'; +const OPENAI_WEB_SEARCH_TOOL_ID = 'openai.web_search'; +const APPLY_PATCH_TOOL_NAME = 'apply_patch'; +const WEB_SEARCH_TOOL_NAME = 'WebSearch'; +const APPLY_PATCH_SENTINEL_NAME = 'maka_open_responses_apply_patch'; +const WEB_SEARCH_SENTINEL_NAME = 'maka_open_responses_web_search'; +const REASONING_EFFORT_HEADER = 'x-maka-open-responses-reasoning-effort'; + +export interface PlaintextOpenResponsesModelOptions { + readonly providerName: string; + readonly apiKey: string; + readonly baseUrl: string; + readonly modelId: string; + readonly fetch: typeof globalThis.fetch; +} + +/** + * Serve the standard plaintext Open Responses dialect behind one model seam. + * + * The upstream provider owns the standard request/response codec, including + * plaintext reasoning replay. This adapter keeps Maka's one measured extension + * local: DeepSeek V4's freeform ApplyPatch tool uses OpenAI custom-tool items, + * which @ai-sdk/open-responses does not yet expose. Internally presenting that + * tool as a function lets the upstream codec retain ordering; the fetch adapter + * restores the exact custom-tool wire before dispatch and translates it back on + * the response side. + */ +export function createPlaintextOpenResponsesModel( + options: PlaintextOpenResponsesModelOptions, +): LanguageModelV4 { + const delegate = createOpenResponses({ + name: options.providerName, + apiKey: options.apiKey, + url: openResponsesUrl(options.baseUrl), + fetch: createDialectFetch(options.fetch), + })(options.modelId); + + return { + specificationVersion: 'v4', + provider: delegate.provider, + modelId: delegate.modelId, + supportedUrls: delegate.supportedUrls, + async doGenerate(callOptions) { + return projectGenerateResult( + await delegate.doGenerate(projectCallOptions(callOptions, options.providerName)), + ); + }, + async doStream(callOptions) { + return projectStreamResult( + await delegate.doStream(projectCallOptions(callOptions, options.providerName)), + ); + }, + }; +} + +function projectCallOptions( + options: LanguageModelV4CallOptions, + providerName: string, +): LanguageModelV4CallOptions { + const providerOptions = options.providerOptions + ? { ...options.providerOptions } + : ({} as SharedV4ProviderOptions); + const dialectOptions = providerOptions[providerName]; + const reasoningEffort = reasoningEffortFrom(dialectOptions?.reasoningEffort); + if (dialectOptions) { + const { reasoningEffort: _reasoningEffort, ...upstreamOptions } = dialectOptions; + if (Object.keys(upstreamOptions).length > 0) providerOptions[providerName] = upstreamOptions; + else delete providerOptions[providerName]; + } + + return { + ...options, + prompt: options.prompt.map(projectPromptMessage), + ...(options.tools ? { tools: options.tools.map(projectTool) } : {}), + ...(options.toolChoice?.type === 'tool' + ? { + toolChoice: { + type: 'tool' as const, + toolName: + options.toolChoice.toolName === APPLY_PATCH_TOOL_NAME + ? APPLY_PATCH_SENTINEL_NAME + : options.toolChoice.toolName === WEB_SEARCH_TOOL_NAME + ? WEB_SEARCH_SENTINEL_NAME + : options.toolChoice.toolName, + }, + } + : {}), + ...(reasoningEffort && reasoningEffort !== 'max' && options.reasoning === undefined + ? { reasoning: reasoningEffort } + : {}), + ...(reasoningEffort === 'max' && options.reasoning === undefined + ? { + headers: { + ...options.headers, + [REASONING_EFFORT_HEADER]: reasoningEffort, + }, + } + : {}), + providerOptions, + }; +} + +function projectPromptMessage(message: LanguageModelV4Message): LanguageModelV4Message { + if (message.role !== 'assistant' && message.role !== 'tool') return message; + let changed = false; + const content = message.content.map((part) => { + if (part.type === 'tool-call' && part.toolName === APPLY_PATCH_TOOL_NAME) { + changed = true; + return { + ...part, + toolName: APPLY_PATCH_SENTINEL_NAME, + input: { input: part.input }, + }; + } + if (part.type === 'tool-result' && part.toolName === APPLY_PATCH_TOOL_NAME) { + changed = true; + return { ...part, toolName: APPLY_PATCH_SENTINEL_NAME }; + } + if ( + (part.type === 'tool-call' || part.type === 'tool-result') && + part.toolName === WEB_SEARCH_TOOL_NAME + ) { + changed = true; + return { ...part, toolName: WEB_SEARCH_SENTINEL_NAME }; + } + return part; + }); + return changed ? ({ ...message, content } as LanguageModelV4Message) : message; +} + +function projectTool( + tool: NonNullable[number], +): NonNullable[number] { + if (tool.type !== 'provider') return tool; + if (tool.id !== OPENAI_CUSTOM_TOOL_ID || tool.name !== APPLY_PATCH_TOOL_NAME) { + if (tool.id !== OPENAI_WEB_SEARCH_TOOL_ID || tool.name !== WEB_SEARCH_TOOL_NAME) { + throw new Error(`open_responses_provider_tool_unsupported:${tool.id}:${tool.name}`); + } + return { + type: 'function', + name: WEB_SEARCH_SENTINEL_NAME, + description: JSON.stringify(tool.args), + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + strict: true, + }; + } + return { + type: 'function' as const, + name: APPLY_PATCH_SENTINEL_NAME, + description: 'Apply a patch to the local workspace.', + inputSchema: { + type: 'object' as const, + properties: { input: { type: 'string' as const } }, + required: ['input'], + additionalProperties: false, + }, + strict: true, + }; +} + +function reasoningEffortFrom( + value: unknown, +): LanguageModelV4CallOptions['reasoning'] | 'max' | undefined { + return value === 'none' || + value === 'minimal' || + value === 'low' || + value === 'medium' || + value === 'high' || + value === 'xhigh' || + value === 'max' + ? value + : undefined; +} + +function projectGenerateResult( + result: LanguageModelV4GenerateResult, +): LanguageModelV4GenerateResult { + return { + ...result, + content: result.content.flatMap(projectModelContent), + ...(result.request?.body !== undefined + ? { request: { ...result.request, body: projectRequestBody(result.request.body) } } + : {}), + }; +} + +function projectStreamResult(result: LanguageModelV4StreamResult): LanguageModelV4StreamResult { + return { + ...result, + stream: result.stream.pipeThrough( + new TransformStream({ + transform(part, controller) { + for (const projected of projectStreamPart(part)) controller.enqueue(projected); + }, + }), + ), + ...(result.request?.body !== undefined + ? { request: { ...result.request, body: projectRequestBody(result.request.body) } } + : {}), + }; +} + +function projectModelContent(content: LanguageModelV4Content): LanguageModelV4Content[] { + if (content.type !== 'tool-call') return [content]; + if (content.toolName === WEB_SEARCH_SENTINEL_NAME) { + return projectWebSearchContent(content); + } + if (content.toolName !== APPLY_PATCH_SENTINEL_NAME) { + return [content]; + } + return [ + { + ...content, + toolName: APPLY_PATCH_TOOL_NAME, + input: customToolInputForModel(content.input), + }, + ]; +} + +function projectStreamPart(part: LanguageModelV4StreamPart): LanguageModelV4StreamPart[] { + if (part.type === 'tool-input-start') { + if (part.toolName === APPLY_PATCH_SENTINEL_NAME) { + return [{ ...part, toolName: APPLY_PATCH_TOOL_NAME }]; + } + if (part.toolName === WEB_SEARCH_SENTINEL_NAME) { + return [{ ...part, toolName: WEB_SEARCH_TOOL_NAME, providerExecuted: true }]; + } + } + if (part.type !== 'tool-call') return [part]; + return projectModelContent(part) as LanguageModelV4StreamPart[]; +} + +function projectWebSearchContent( + content: Extract, +): LanguageModelV4Content[] { + let payload: Record = {}; + try { + payload = asRecord(JSON.parse(content.input) as unknown) ?? {}; + } catch { + // A malformed hosted result remains visible as an empty provider result. + } + const result: JSONObject = {}; + if (isJSONValue(payload.action)) result.action = payload.action; + if (isJSONValue(payload.sources)) result.sources = payload.sources; + return [ + { + ...content, + toolName: WEB_SEARCH_TOOL_NAME, + input: '{}', + providerExecuted: true, + }, + { + type: 'tool-result', + toolCallId: content.toolCallId, + toolName: WEB_SEARCH_TOOL_NAME, + result, + }, + ]; +} + +function customToolInputForModel(input: string): string { + try { + const parsed = JSON.parse(input) as unknown; + const record = asRecord(parsed); + if (record && typeof record.input === 'string') return JSON.stringify(record.input); + } catch { + // Preserve malformed provider input so the AI SDK's ordinary validation path owns the error. + } + return input; +} + +function createDialectFetch(fetchImpl: typeof globalThis.fetch): typeof globalThis.fetch { + return async (input, init) => { + const response = await fetchImpl(input, projectRequestInit(init)); + return projectResponse(response); + }; +} + +function projectRequestInit(init: RequestInit | undefined): RequestInit | undefined { + if (!init) return init; + const headers = new Headers(init.headers); + const reasoningEffort = headers.get(REASONING_EFFORT_HEADER); + headers.delete(REASONING_EFFORT_HEADER); + if (typeof init.body !== 'string') { + return reasoningEffort ? { ...init, headers } : init; + } + let body: unknown; + try { + body = JSON.parse(init.body) as unknown; + } catch { + return reasoningEffort ? { ...init, headers } : init; + } + const projected = projectRequestBody(body); + const record = asRecord(projected); + const reasoning = asRecord(record?.reasoning); + return { + ...init, + headers, + body: JSON.stringify( + record && reasoningEffort === 'max' + ? { ...record, reasoning: { ...reasoning, effort: reasoningEffort } } + : projected, + ), + }; +} + +function projectRequestBody(body: unknown): unknown { + const record = asRecord(body); + if (!record) return body; + const customCallIds = new Set(); + const webSearchCallIds = new Set(); + const input = Array.isArray(record.input) + ? record.input.flatMap((item) => { + const itemRecord = asRecord(item); + if (!itemRecord) return [item]; + if (itemRecord.type === 'function_call' && itemRecord.name === APPLY_PATCH_SENTINEL_NAME) { + if (typeof itemRecord.call_id !== 'string') { + throw new Error('open_responses_custom_call_id'); + } + const customInput = customToolInputFromArguments(itemRecord.arguments); + customCallIds.add(itemRecord.call_id); + return [ + { + type: 'custom_tool_call', + call_id: itemRecord.call_id, + name: APPLY_PATCH_TOOL_NAME, + input: customInput, + }, + ]; + } + if (itemRecord.type === 'function_call' && itemRecord.name === WEB_SEARCH_SENTINEL_NAME) { + if (typeof itemRecord.call_id !== 'string') { + throw new Error('open_responses_web_search_call_id'); + } + webSearchCallIds.add(itemRecord.call_id); + return [{ type: 'item_reference', id: itemRecord.call_id }]; + } + if ( + itemRecord.type === 'function_call_output' && + typeof itemRecord.call_id === 'string' && + webSearchCallIds.has(itemRecord.call_id) + ) { + return []; + } + if ( + itemRecord.type === 'function_call_output' && + typeof itemRecord.call_id === 'string' && + customCallIds.has(itemRecord.call_id) + ) { + return [ + { + type: 'custom_tool_call_output', + call_id: itemRecord.call_id, + output: itemRecord.output, + }, + ]; + } + return [item]; + }) + : record.input; + const tools = Array.isArray(record.tools) + ? record.tools.map((tool) => { + const toolRecord = asRecord(tool); + if (toolRecord?.type !== 'function') return tool; + if (toolRecord.name === APPLY_PATCH_SENTINEL_NAME) { + return { type: 'custom', name: APPLY_PATCH_TOOL_NAME }; + } + if (toolRecord.name === WEB_SEARCH_SENTINEL_NAME) { + return projectWebSearchTool(toolRecord.description); + } + return tool; + }) + : record.tools; + return { + ...record, + store: false, + ...(input !== undefined ? { input } : {}), + ...(tools !== undefined ? { tools } : {}), + }; +} + +function customToolInputFromArguments(argumentsValue: unknown): string { + if (typeof argumentsValue !== 'string') throw new Error('open_responses_custom_arguments'); + let parsed: unknown; + try { + parsed = JSON.parse(argumentsValue) as unknown; + } catch { + throw new Error('open_responses_custom_arguments'); + } + const record = asRecord(parsed); + if (!record || typeof record.input !== 'string') { + throw new Error('open_responses_custom_arguments'); + } + return record.input; +} + +function webSearchArgsFromDescription(value: unknown): Record { + if (typeof value !== 'string') return {}; + try { + return asRecord(JSON.parse(value) as unknown) ?? {}; + } catch { + return {}; + } +} + +function projectWebSearchTool(description: unknown): Record { + const args = webSearchArgsFromDescription(description); + const filters = asRecord(args.filters); + const userLocation = asRecord(args.userLocation); + return { + type: 'web_search', + ...(args.searchContextSize === 'low' || + args.searchContextSize === 'medium' || + args.searchContextSize === 'high' + ? { search_context_size: args.searchContextSize } + : {}), + ...(typeof args.externalWebAccess === 'boolean' + ? { external_web_access: args.externalWebAccess } + : {}), + ...(filters + ? { + filters: { + ...(stringArray(filters.allowedDomains) + ? { allowed_domains: filters.allowedDomains } + : {}), + ...(stringArray(filters.blockedDomains) + ? { blocked_domains: filters.blockedDomains } + : {}), + }, + } + : {}), + ...(userLocation ? { user_location: userLocation } : {}), + }; +} + +function projectResponse(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(projectEventStream())); + } + 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(projectJsonResponse(body))); + controller.close(); + }, + }), + ); +} + +function projectJsonResponse(body: string): string { + let payload: unknown; + try { + payload = JSON.parse(body) as unknown; + } catch { + return body; + } + const record = asRecord(payload); + if (!record || !Array.isArray(record.output)) return body; + const output = record.output.map(projectResponseItem); + return JSON.stringify({ ...record, output }); +} + +function projectResponseItem(item: unknown): unknown { + const record = asRecord(item); + if (!record) return item; + if (record.type === 'web_search_call' && typeof record.id === 'string') { + return { + ...record, + type: 'function_call', + call_id: record.id, + name: WEB_SEARCH_SENTINEL_NAME, + arguments: JSON.stringify({ + ...(record.action !== undefined ? { action: record.action } : {}), + ...(record.sources !== undefined ? { sources: record.sources } : {}), + }), + }; + } + if (record.type !== 'custom_tool_call' || record.name !== APPLY_PATCH_TOOL_NAME) { + return item; + } + const { input, ...rest } = record; + return { + ...rest, + type: 'function_call', + name: APPLY_PATCH_SENTINEL_NAME, + arguments: JSON.stringify({ input: typeof input === 'string' ? input : '' }), + }; +} + +function projectEventStream(): 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) { + const projectedLines = projectEventLine(line); + for (const [index, projected] of projectedLines.entries()) { + controller.enqueue( + encoder.encode(`${projected}\n${index < projectedLines.length - 1 ? '\n' : ''}`), + ); + } + } + }, + flush(controller) { + pending += decoder.decode(); + if (!pending) return; + for (const projected of projectEventLine(pending)) { + controller.enqueue(encoder.encode(projected)); + } + }, + }); +} + +function projectEventLine(line: string): string[] { + if (!line.startsWith('data:')) return [line]; + const raw = line.slice('data:'.length).trim(); + if (!raw || raw === '[DONE]') return [line]; + let event: unknown; + try { + event = JSON.parse(raw) as unknown; + } catch { + return [line]; + } + const eventRecord = asRecord(event); + if (!eventRecord) return [line]; + if (eventRecord.type === 'response.custom_tool_call_input.delta') return []; + if ( + (eventRecord.type === 'response.output_item.added' || + eventRecord.type === 'response.output_item.done') && + ((asRecord(eventRecord.item)?.type === 'custom_tool_call' && + asRecord(eventRecord.item)?.name === APPLY_PATCH_TOOL_NAME) || + asRecord(eventRecord.item)?.type === 'web_search_call') + ) { + const customItem = asRecord(eventRecord.item)!; + const item = projectResponseItem(customItem); + const projectedItem = asRecord(item); + const projectedEvent = `data: ${JSON.stringify({ ...eventRecord, item })}`; + if (eventRecord.type !== 'response.output_item.done') return [projectedEvent]; + const argumentsDone = { + type: 'response.function_call_arguments.done', + sequence_number: + typeof eventRecord.sequence_number === 'number' ? eventRecord.sequence_number : 0, + item_id: projectedItem?.id, + output_index: eventRecord.output_index, + call_id: projectedItem?.call_id, + arguments: typeof projectedItem?.arguments === 'string' ? projectedItem.arguments : '{}', + }; + return [`data: ${JSON.stringify(argumentsDone)}`, projectedEvent]; + } + return [line]; +} + +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(); +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function stringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} 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); -} From 37342191a5a7cb7bfb8dc62940e419e93c751598 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 13 Aug 2026 23:22:28 +0800 Subject: [PATCH 02/12] test(runtime): pin plaintext Responses round trips --- .../src/__tests__/ai-sdk-backend.test.ts | 122 ++++++- .../src/__tests__/model-adapter.test.ts | 27 +- .../__tests__/model-factory-thinking.test.ts | 19 +- ...enai-responses-plaintext-reasoning.test.ts | 125 +------- .../__tests__/responses-wire-contract.test.ts | 303 ++++++++++++++++-- 5 files changed, 430 insertions(+), 166 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index b2933440c5..753a80aa90 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -12448,11 +12448,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,17 +12500,112 @@ 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: '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)); + assert.deepEqual( + assistant.content.find((part) => part.type === 'reasoning'), + { type: 'reasoning', text: 'reasoning about the tool', providerOptions: undefined }, + ); assert.ok( assistant.content.some((part) => part.type === 'tool-call' && part.toolCallId === 'tool-1'), ); diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index fec15a4b85..8b3255ace8 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -67,7 +67,7 @@ describe('ModelAdapter stream and error normalization', () => { toolResults: true, signedThinking: false, unsignedThinking: true, - openAiResponsesEncryptedThinking: false, + responsesThinking: 'none', }); }); @@ -121,7 +121,30 @@ describe('ModelAdapter stream and error normalization', () => { toolResults: true, signedThinking: false, unsignedThinking: false, - openAiResponsesEncryptedThinking: true, + responsesThinking: 'openai-encrypted', + }); + }); + + 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, + signedThinking: false, + unsignedThinking: false, + responsesThinking: 'open-responses-plaintext', }); }); diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 59db3c82f0..1bee590148 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -203,20 +203,15 @@ 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 speaks the plaintext Open Responses dialect, so its effort + // stays in the DeepSeek namespace and no encrypted-content option leaks in. 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' }, }); + assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'off'), {}); assert.deepEqual([...thinkingVariantsForModel('zai-coding-plan', 'glm-5.1')], []); assert.deepEqual([...thinkingVariantsForModel('zai-coding-plan', 'glm-4.5-air')], []); // miss model (deepseek-chat non-reasoning) drops level @@ -499,7 +494,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 uses its own namespace on both supported dialects', () => { const chatConnection: LlmConnection = { ...conn('deepseek', 'deepseek'), models: [{ id: 'deepseek-v4-pro', apiProtocol: 'openai-chat' }], @@ -509,7 +504,7 @@ describe('buildProviderOptions: openai-compatible namespace', () => { }); 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__/openai-responses-plaintext-reasoning.test.ts b/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts index 575a43f6b1..016b74205c 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 '@maka/runtime/model-factory'; function conn(providerType: LlmConnection['providerType']): LlmConnection { return { @@ -133,21 +132,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 +144,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 = ''; @@ -243,110 +230,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__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index 1d89f6592b..d8ede8e709 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -24,17 +24,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 +43,7 @@ describe('responses wire contract', () => { } }); - test('every Responses model asks for encrypted reasoning', () => { + test('every encrypted Responses dialect 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 +58,18 @@ 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 === 'open-responses-plaintext' + ) { + 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. @@ -384,7 +379,7 @@ describe('responses wire request body', () => { }, ], tools: [{ ...(tools.apply_patch as object), name: 'apply_patch' } as never], - providerOptions: { openai: { store: false } }, + providerOptions: buildProviderOptions(connection, 'deepseek-v4-flash'), }); assert.deepEqual((body?.tools as unknown[] | undefined)?.[0], { @@ -401,16 +396,191 @@ describe('responses wire request body', () => { ]); }); - test('a non-OpenAI-named Responses model still asks for encrypted reasoning', async () => { - // The options shape alone does not prove the wire: the SDK only adds the - // include when it also believes the model reasons, and it decides that by - // parsing the model id. `deepseek-v4-flash` fails that parse, so this - // asserts the body the provider actually receives rather than the options - // we hand the SDK. Without `forceReasoning` the include silently vanishes - // while the options still look right. + test('returns streamed DeepSeek custom apply_patch calls through the model interface', async () => { + const patch = '*** Begin Patch\n*** Delete File: old.txt\n*** End Patch'; + const events = [ + { + type: 'response.output_item.added', + output_index: 0, + sequence_number: 1, + item: { + type: 'custom_tool_call', + id: 'custom-1', + status: 'in_progress', + call_id: 'call-1', + name: 'apply_patch', + input: '', + }, + }, + { + type: 'response.custom_tool_call_input.delta', + output_index: 0, + sequence_number: 2, + item_id: 'custom-1', + delta: patch, + }, + { + type: 'response.output_item.done', + output_index: 0, + sequence_number: 3, + item: { + type: 'custom_tool_call', + id: 'custom-1', + status: 'completed', + call_id: 'call-1', + name: 'apply_patch', + input: patch, + }, + }, + { + type: 'response.completed', + sequence_number: 4, + response: { + id: 'r', + object: 'response', + created_at: 0, + model: 'deepseek-v4-flash', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + ]; + const fetch = (async () => + 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 model = getAIModel({ + connection: conn('deepseek'), + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + + const { stream } = await model.doStream({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'Delete old.txt' }] }], + tools: [ + { + type: 'provider', + id: 'openai.custom', + name: 'apply_patch', + args: {}, + }, + ], + }); + const parts = []; + for await (const part of stream) { + parts.push(part); + } + + assert.deepEqual( + parts.filter((part) => part.type === 'tool-call'), + [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'apply_patch', + input: JSON.stringify(patch), + }, + ], + JSON.stringify(parts), + ); + }); + + test('returns streamed DeepSeek hosted web search calls and results', async () => { + const action = { type: 'search', queries: ['latest Maka'] }; + let requestBody: Record | undefined; + const events = [ + { + type: 'response.output_item.added', + output_index: 0, + sequence_number: 1, + item: { + type: 'web_search_call', + id: 'search-1', + status: 'in_progress', + }, + }, + { + type: 'response.output_item.done', + output_index: 0, + sequence_number: 2, + item: { + type: 'web_search_call', + id: 'search-1', + status: 'completed', + action, + }, + }, + { + type: 'response.completed', + sequence_number: 3, + response: { + id: 'r', + object: 'response', + created_at: 0, + model: 'deepseek-v4-flash', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + ]; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)); + 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 model = getAIModel({ + connection: conn('deepseek'), + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + + const { stream } = await model.doStream({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'Search.' }] }], + tools: [ + { + type: 'provider', + id: 'openai.web_search', + name: 'WebSearch', + args: { searchContextSize: 'medium' }, + }, + ], + }); + const parts = []; + for await (const part of stream) { + if (part.type === 'tool-call' || part.type === 'tool-result') parts.push(part); + } + + assert.deepEqual(parts, [ + { + type: 'tool-call', + toolCallId: 'search-1', + toolName: 'WebSearch', + input: '{}', + providerExecuted: true, + }, + { + type: 'tool-result', + toolCallId: 'search-1', + toolName: 'WebSearch', + result: { action }, + }, + ]); + assert.deepEqual(requestBody?.tools, [{ type: 'web_search', search_context_size: 'medium' }]); + }); + + test('DeepSeek uses plaintext Responses options without asking for encrypted content', async () => { let body: Record | undefined; + let headers: Headers | undefined; const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { body = JSON.parse(String(init?.body)); + headers = new Headers(init?.headers); return new Response( JSON.stringify({ id: 'r', @@ -436,7 +606,92 @@ describe('responses wire request body', () => { }); assert.equal(body?.store, false); - assert.deepEqual(body?.include, ['reasoning.encrypted_content']); + assert.equal(body?.include, undefined); assert.equal((body?.reasoning as { effort?: string } | undefined)?.effort, 'max'); + assert.equal(headers?.has('x-maka-open-responses-reasoning-effort'), false); + }); + + 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)); + 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('deepseek'); + const model = getAIModel({ + connection, + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + + 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-read', + toolName: 'Read', + input: { path: 'package.json' }, + }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'call-read', + toolName: 'Read', + output: { type: 'text', value: '{"name":"maka"}' }, + }, + ], + }, + ], + tools: [ + { + type: 'function', + name: 'Read', + inputSchema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + additionalProperties: false, + }, + }, + ], + }); + + 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(input?.[2], { + type: 'function_call', + call_id: 'call-read', + name: 'Read', + arguments: '{"path":"package.json"}', + }); + assert.deepEqual(input?.[3], { + type: 'function_call_output', + call_id: 'call-read', + output: '{"name":"maka"}', + }); }); }); From ed2b512ae34d577fec8f01691c6f4f767c8784ee Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 13 Aug 2026 23:22:28 +0800 Subject: [PATCH 03/12] chore(licenses): record open-responses dependencies --- .../licenses/npm/THIRD_PARTY_NOTICES.txt | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 692051b124..8607af26af 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -119,7 +119,33 @@ limitations under the License. ================================================================================ +<<<<<<< HEAD Package: @ai-sdk/openai@4.0.42 +======= +Package: @ai-sdk/open-responses@2.0.27 +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.36 +>>>>>>> be395bd54 (chore(licenses): record open-responses dependencies) Declared license: Apache-2.0 Selected license: Apache-2.0 Repository: https://github.com/vercel/ai#packages/openai @@ -535,6 +561,250 @@ OTHER DEALINGS IN THE FONT SOFTWARE. ================================================================================ +Package: @ai-sdk/provider-utils@5.0.27 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: https://github.com/vercel/ai#packages/provider-utils + +--- VERSION-PINNED LICENSE TEXT OVERRIDE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. + +THIRD-PARTY COMPONENTS + +trycua/cua cursor-overlay + +Source: https://github.com/trycua/cua +Revision: 8c921b2b3bf13494724ead4f0a814d80c56a7e8b +Copyright (c) 2025 Cua AI, Inc. +License: MIT + +Maka's agent-cursor renderer and palette include adaptations of this component. +The following MIT License applies to that material: + +MIT License + +Copyright (c) 2025 Cua AI, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: @antfu/install-pkg@1.1.0 Declared license: MIT Selected license: MIT From 904dc2aea1f1d137fc50aa79d2ff14297850e16c Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 14 Aug 2026 00:35:48 +0800 Subject: [PATCH 04/12] refactor(runtime): rely on upstream Open Responses --- .../src/__tests__/model-web-search.test.ts | 6 +- packages/core/src/model-web-search.ts | 4 + packages/core/src/provider-registry.ts | 1 - .../execution-model-composition.test.ts | 6 +- .../src/__tests__/ai-sdk-backend.test.ts | 93 ++- .../src/__tests__/apply-patch-profile.test.ts | 29 +- .../__tests__/model-factory-thinking.test.ts | 16 +- .../__tests__/native-web-search-tool.test.ts | 13 +- .../__tests__/provider-conformance.test.ts | 137 +--- .../__tests__/responses-wire-contract.test.ts | 293 +-------- packages/runtime/src/ai-sdk-backend.ts | 1 + packages/runtime/src/model-adapter.ts | 19 + packages/runtime/src/model-factory.ts | 25 +- .../src/open-responses-plaintext-model.ts | 596 ------------------ packages/runtime/src/provider-urls.ts | 8 + 15 files changed, 192 insertions(+), 1055 deletions(-) delete mode 100644 packages/runtime/src/open-responses-plaintext-model.ts 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/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 09d518e388..1d1f0b81a2 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -840,7 +840,6 @@ const providerRegistry = { name: 'provider', supportsOpenAiResponses: true, responsesDialect: 'open-responses', - applyPatchProtocol: 'codex-v4a-freeform', }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', 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..3606520dde 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); @@ -3180,6 +3181,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/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 753a80aa90..044e6bb83b 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,7 +217,7 @@ describe('AiSdkBackend ApplyPatch routing', () => { }); }); - test('replays a durable DeepSeek freeform apply_patch result as plain text', async () => { + test('preserves a durable DeepSeek apply_patch fact without inventing a custom wire', async () => { const model = completionModel(); const backend = createTestAiSdkBackend({ sessionId: 'session-1', @@ -291,13 +291,17 @@ describe('AiSdkBackend ApplyPatch routing', () => { const toolCall = (compactPrompt(model) as Array<{ role: string; content: any[] }>) .find((message) => message.role === 'assistant') ?.content.find((part) => part.type === 'tool-call'); - assert.equal( - toolCall?.input, - '*** Begin Patch\n*** Update File: file.txt\n@@\n-before\n+after\n*** End Patch', - ); + assert.deepEqual(toolCall?.input, { + callId: 'call-1', + operation: { + type: 'update_file', + path: 'file.txt', + diff: '@@\n-before\n+after', + }, + }); assert.deepEqual(toolResult?.output, { - type: 'text', - value: 'Applied 1 file operation.', + type: 'json', + value: { status: 'completed', output: 'Applied 1 file operation.' }, }); }); @@ -12233,14 +12237,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 +12256,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 +12307,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 +12327,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(), @@ -12611,6 +12615,53 @@ describe('AiSdkBackend thinking persistence', () => { ); }); + test('maps DeepSeek max reasoning to the upstream Open Responses xhigh level', 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: 'xhigh' }); + 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..a0772eef7a 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,6 +30,25 @@ describe('ApplyPatch profile routing', () => { ); }); + 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('selects Codex V4A freeform for declared DeepSeek V4 Responses models', () => { assert.deepEqual( resolveApplyPatchProfile( diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 1bee590148..7d2bf6317c 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -203,14 +203,10 @@ describe('buildProviderOptions: thinking level', () => { [...thinkingVariantsForModel('deepseek', 'deepseek-v4-flash')], ['high', 'max'], ); - // DeepSeek V4 speaks the plaintext Open Responses dialect, so its effort - // stays in the DeepSeek namespace and no encrypted-content option leaks in. - assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'high'), { - deepseek: { reasoningEffort: 'high' }, - }); - assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'max'), { - deepseek: { reasoningEffort: 'max' }, - }); + // DeepSeek V4 speaks the plaintext Open Responses dialect. Its effort is + // a top-level AI SDK option, not providerOptions owned by Maka. + assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'high'), {}); + assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'max'), {}); assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'off'), {}); assert.deepEqual([...thinkingVariantsForModel('zai-coding-plan', 'glm-5.1')], []); assert.deepEqual([...thinkingVariantsForModel('zai-coding-plan', 'glm-4.5-air')], []); @@ -494,7 +490,7 @@ describe('buildProviderOptions: openai-compatible namespace', () => { { 'zai-coding-plan': { reasoningEffort: 'max' } }, ); }); - test('deepseek uses its own namespace on both supported dialects', () => { + test('deepseek uses provider options only for the chat dialect', () => { const chatConnection: LlmConnection = { ...conn('deepseek', 'deepseek'), models: [{ id: 'deepseek-v4-pro', apiProtocol: 'openai-chat' }], @@ -504,7 +500,7 @@ describe('buildProviderOptions: openai-compatible namespace', () => { }); assert.deepEqual( buildProviderOptions(conn('deepseek', 'deepseek'), 'deepseek-v4-flash', '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__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index 1a4c61b2bf..80dfb61f70 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 () => { diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index d8ede8e709..d4ebae1c1e 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -6,11 +6,10 @@ 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 { resolveModelRuntime } from '../model-runtime.js'; import { lowerModelTools } from '../model-adapter.js'; import { openAiCodexCompactionMessages } from '../openai-codex-history-compactor.js'; +import { openResponsesUrl } from '../provider-urls.js'; function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmConnection { return { @@ -43,6 +42,18 @@ describe('responses wire contract', () => { } }); + 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('every encrypted Responses dialect 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 @@ -302,279 +313,6 @@ describe('responses wire request body', () => { }); }); - test('sends DeepSeek-compatible freeform apply_patch calls and plain-text results', async () => { - let body: Record | undefined; - const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { - body = JSON.parse(String(init?.body)); - 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('deepseek'); - const model = getAIModel({ - connection, - apiKey: 'test-key', - 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: 'assistant', - content: [ - { - type: 'tool-call', - toolCallId: 'call-1', - toolName: 'apply_patch', - input: patch, - }, - ], - }, - { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: 'call-1', - toolName: 'apply_patch', - output: { type: 'text', value: 'Applied 1 file operation.' }, - }, - ], - }, - ], - tools: [{ ...(tools.apply_patch as object), name: 'apply_patch' } as never], - providerOptions: buildProviderOptions(connection, 'deepseek-v4-flash'), - }); - - assert.deepEqual((body?.tools as unknown[] | undefined)?.[0], { - type: 'custom', - name: 'apply_patch', - }); - 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('returns streamed DeepSeek custom apply_patch calls through the model interface', async () => { - const patch = '*** Begin Patch\n*** Delete File: old.txt\n*** End Patch'; - const events = [ - { - type: 'response.output_item.added', - output_index: 0, - sequence_number: 1, - item: { - type: 'custom_tool_call', - id: 'custom-1', - status: 'in_progress', - call_id: 'call-1', - name: 'apply_patch', - input: '', - }, - }, - { - type: 'response.custom_tool_call_input.delta', - output_index: 0, - sequence_number: 2, - item_id: 'custom-1', - delta: patch, - }, - { - type: 'response.output_item.done', - output_index: 0, - sequence_number: 3, - item: { - type: 'custom_tool_call', - id: 'custom-1', - status: 'completed', - call_id: 'call-1', - name: 'apply_patch', - input: patch, - }, - }, - { - type: 'response.completed', - sequence_number: 4, - response: { - id: 'r', - object: 'response', - created_at: 0, - model: 'deepseek-v4-flash', - status: 'completed', - output: [], - usage: { input_tokens: 1, output_tokens: 1 }, - }, - }, - ]; - const fetch = (async () => - 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 model = getAIModel({ - connection: conn('deepseek'), - apiKey: 'test-key', - modelId: 'deepseek-v4-flash', - fetch, - }); - - const { stream } = await model.doStream({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'Delete old.txt' }] }], - tools: [ - { - type: 'provider', - id: 'openai.custom', - name: 'apply_patch', - args: {}, - }, - ], - }); - const parts = []; - for await (const part of stream) { - parts.push(part); - } - - assert.deepEqual( - parts.filter((part) => part.type === 'tool-call'), - [ - { - type: 'tool-call', - toolCallId: 'call-1', - toolName: 'apply_patch', - input: JSON.stringify(patch), - }, - ], - JSON.stringify(parts), - ); - }); - - test('returns streamed DeepSeek hosted web search calls and results', async () => { - const action = { type: 'search', queries: ['latest Maka'] }; - let requestBody: Record | undefined; - const events = [ - { - type: 'response.output_item.added', - output_index: 0, - sequence_number: 1, - item: { - type: 'web_search_call', - id: 'search-1', - status: 'in_progress', - }, - }, - { - type: 'response.output_item.done', - output_index: 0, - sequence_number: 2, - item: { - type: 'web_search_call', - id: 'search-1', - status: 'completed', - action, - }, - }, - { - type: 'response.completed', - sequence_number: 3, - response: { - id: 'r', - object: 'response', - created_at: 0, - model: 'deepseek-v4-flash', - status: 'completed', - output: [], - usage: { input_tokens: 1, output_tokens: 1 }, - }, - }, - ]; - const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { - requestBody = JSON.parse(String(init?.body)); - 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 model = getAIModel({ - connection: conn('deepseek'), - apiKey: 'test-key', - modelId: 'deepseek-v4-flash', - fetch, - }); - - const { stream } = await model.doStream({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'Search.' }] }], - tools: [ - { - type: 'provider', - id: 'openai.web_search', - name: 'WebSearch', - args: { searchContextSize: 'medium' }, - }, - ], - }); - const parts = []; - for await (const part of stream) { - if (part.type === 'tool-call' || part.type === 'tool-result') parts.push(part); - } - - assert.deepEqual(parts, [ - { - type: 'tool-call', - toolCallId: 'search-1', - toolName: 'WebSearch', - input: '{}', - providerExecuted: true, - }, - { - type: 'tool-result', - toolCallId: 'search-1', - toolName: 'WebSearch', - result: { action }, - }, - ]); - assert.deepEqual(requestBody?.tools, [{ type: 'web_search', search_context_size: 'medium' }]); - }); - test('DeepSeek uses plaintext Responses options without asking for encrypted content', async () => { let body: Record | undefined; let headers: Headers | undefined; @@ -602,12 +340,13 @@ describe('responses wire request body', () => { }); await model.doGenerate({ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + reasoning: 'xhigh', providerOptions: buildProviderOptions(connection, 'deepseek-v4-flash', 'max'), }); - assert.equal(body?.store, false); + assert.equal(body?.store, undefined); assert.equal(body?.include, undefined); - assert.equal((body?.reasoning as { effort?: string } | undefined)?.effort, 'max'); + assert.equal((body?.reasoning as { effort?: string } | undefined)?.effort, 'xhigh'); assert.equal(headers?.has('x-maka-open-responses-reasoning-effort'), false); }); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index dc61daa1d8..a8652d51e7 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -1090,6 +1090,7 @@ export class AiSdkBackend implements AgentBackend { modelId: input.modelId, modelFactory: input.modelFactory, providerOptions: input.providerOptions, + reasoningLevel: input.header.thinkingLevel, newId: this.newId, now: this.now, ...(input.openAiResponsesTransportState diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index d9e254d025..c584473d74 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -6,6 +6,7 @@ import { type RuntimeExecutionConnection, } from '@maka/core/llm-connections'; import { lookupModelMetadata } from '@maka/core/model-metadata'; +import type { ThinkingLevel } from '@maka/core/model-thinking'; import { generalizedErrorMessage } from '@maka/core/redaction'; import type { CacheMissInputSource } from '@maka/core/usage-stats/types'; import { rawFinishReasonString } from './model-protocol.js'; @@ -95,6 +96,8 @@ export interface ModelAdapterInput { modelId: string; modelFactory: ModelFactory; providerOptions?: Record; + /** Session-selected effort; Open Responses consumes it at the top-level seam. */ + reasoningLevel?: ThinkingLevel; newId: () => string; now: () => number; /** Test seam; production adapters own one state instance for their lifetime. */ @@ -275,6 +278,10 @@ export class ModelAdapter { continuation.previousResponseId, ) : this.input.providerOptions; + const reasoning = + this.runtime.reasoningReplay.kind === 'open-responses-plaintext' + ? openResponsesReasoning(this.input.reasoningLevel) + : undefined; const sdkResult = streamText({ model: trackedModel, messages: continuation.messages, @@ -291,6 +298,7 @@ export class ModelAdapter { ...(input.system ? { instructions: input.system } : {}), ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), providerOptions, + ...(reasoning ? { reasoning } : {}), ...(responsesLane ? { headers: { [OPENAI_RESPONSES_LANE_HEADER]: responsesLane } } : {}), maxRetries: 0, // Preserve the final request's Maka-owned message projection without @@ -518,6 +526,17 @@ export class ModelAdapter { } } +function openResponsesReasoning( + level: ThinkingLevel | undefined, +): 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | undefined { + if (!level) return undefined; + if (level === 'off') return 'none'; + // @ai-sdk/open-responses has no `max` level. Keep this an explicit + // compatibility mapping instead of rewriting the provider request body. + if (level === 'max') return 'xhigh'; + return level; +} + interface ModelStepSettlementEvidence { aborted: boolean; failure?: ModelFailure; diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 4fcda4061b..2cd4323e00 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,8 @@ import { createOpenAiChatReasoningTransportState, type OpenAiChatReasoningTransportState, } from './openai-chat-reasoning-transport.js'; -import { createPlaintextOpenResponsesModel } from './open-responses-plaintext-model.js'; import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js'; -import { anthropicV1BaseUrl, googleV1BetaBaseUrl } from './provider-urls.js'; +import { anthropicV1BaseUrl, googleV1BetaBaseUrl, 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'; @@ -146,13 +146,12 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { } if (wire === 'openai-responses') { if (reasoningReplay.kind === 'open-responses-plaintext') { - return createPlaintextOpenResponsesModel({ - providerName: openAiCompatibleProviderName(adapter, connection), + return createOpenResponses({ + name: openAiCompatibleProviderName(adapter, connection), apiKey, - baseUrl: baseURL, - modelId, + url: openResponsesUrl(baseURL), fetch: requestFetch, - }); + })(modelId); } return createOpenAI({ apiKey, @@ -514,19 +513,13 @@ function buildFamilyWire( // A Responses wire still has two distinct replay contracts. OpenAI's native // dialect uses encrypted content and therefore reads `openai` options; // `store: false` asks the SDK to include that replay token. Open Responses - // carries plaintext reasoning items and reads the compatible provider's own - // namespace. `getAIModel` selects the matching codec from the same resolved - // runtime, so a provider's effort cannot silently land in the wrong one. + // carries plaintext reasoning items; ModelAdapter passes the Session's + // selected effort through the AI SDK's top-level `reasoning` option. if (wire === 'openai-responses') { // Connection-aware: a relay model's declared variants count too. const reasons = thinkingVariantsForConnection(connection, modelId).length > 0; if (reasoningReplay.kind === 'open-responses-plaintext') { - if (!reasoningEffort) return {}; - return { - [openAiCompatibleProviderOptionsKey(adapter, connection)]: { - reasoningEffort, - }, - }; + return {}; } return { openai: { diff --git a/packages/runtime/src/open-responses-plaintext-model.ts b/packages/runtime/src/open-responses-plaintext-model.ts deleted file mode 100644 index fdca003575..0000000000 --- a/packages/runtime/src/open-responses-plaintext-model.ts +++ /dev/null @@ -1,596 +0,0 @@ -import { createOpenResponses } from '@ai-sdk/open-responses'; -import { - isJSONValue, - type JSONObject, - LanguageModelV4, - LanguageModelV4CallOptions, - LanguageModelV4Content, - LanguageModelV4GenerateResult, - LanguageModelV4Message, - LanguageModelV4StreamPart, - LanguageModelV4StreamResult, - SharedV4ProviderOptions, -} from '@ai-sdk/provider'; -import { responseWithBody } from './http-response.js'; - -const OPENAI_CUSTOM_TOOL_ID = 'openai.custom'; -const OPENAI_WEB_SEARCH_TOOL_ID = 'openai.web_search'; -const APPLY_PATCH_TOOL_NAME = 'apply_patch'; -const WEB_SEARCH_TOOL_NAME = 'WebSearch'; -const APPLY_PATCH_SENTINEL_NAME = 'maka_open_responses_apply_patch'; -const WEB_SEARCH_SENTINEL_NAME = 'maka_open_responses_web_search'; -const REASONING_EFFORT_HEADER = 'x-maka-open-responses-reasoning-effort'; - -export interface PlaintextOpenResponsesModelOptions { - readonly providerName: string; - readonly apiKey: string; - readonly baseUrl: string; - readonly modelId: string; - readonly fetch: typeof globalThis.fetch; -} - -/** - * Serve the standard plaintext Open Responses dialect behind one model seam. - * - * The upstream provider owns the standard request/response codec, including - * plaintext reasoning replay. This adapter keeps Maka's one measured extension - * local: DeepSeek V4's freeform ApplyPatch tool uses OpenAI custom-tool items, - * which @ai-sdk/open-responses does not yet expose. Internally presenting that - * tool as a function lets the upstream codec retain ordering; the fetch adapter - * restores the exact custom-tool wire before dispatch and translates it back on - * the response side. - */ -export function createPlaintextOpenResponsesModel( - options: PlaintextOpenResponsesModelOptions, -): LanguageModelV4 { - const delegate = createOpenResponses({ - name: options.providerName, - apiKey: options.apiKey, - url: openResponsesUrl(options.baseUrl), - fetch: createDialectFetch(options.fetch), - })(options.modelId); - - return { - specificationVersion: 'v4', - provider: delegate.provider, - modelId: delegate.modelId, - supportedUrls: delegate.supportedUrls, - async doGenerate(callOptions) { - return projectGenerateResult( - await delegate.doGenerate(projectCallOptions(callOptions, options.providerName)), - ); - }, - async doStream(callOptions) { - return projectStreamResult( - await delegate.doStream(projectCallOptions(callOptions, options.providerName)), - ); - }, - }; -} - -function projectCallOptions( - options: LanguageModelV4CallOptions, - providerName: string, -): LanguageModelV4CallOptions { - const providerOptions = options.providerOptions - ? { ...options.providerOptions } - : ({} as SharedV4ProviderOptions); - const dialectOptions = providerOptions[providerName]; - const reasoningEffort = reasoningEffortFrom(dialectOptions?.reasoningEffort); - if (dialectOptions) { - const { reasoningEffort: _reasoningEffort, ...upstreamOptions } = dialectOptions; - if (Object.keys(upstreamOptions).length > 0) providerOptions[providerName] = upstreamOptions; - else delete providerOptions[providerName]; - } - - return { - ...options, - prompt: options.prompt.map(projectPromptMessage), - ...(options.tools ? { tools: options.tools.map(projectTool) } : {}), - ...(options.toolChoice?.type === 'tool' - ? { - toolChoice: { - type: 'tool' as const, - toolName: - options.toolChoice.toolName === APPLY_PATCH_TOOL_NAME - ? APPLY_PATCH_SENTINEL_NAME - : options.toolChoice.toolName === WEB_SEARCH_TOOL_NAME - ? WEB_SEARCH_SENTINEL_NAME - : options.toolChoice.toolName, - }, - } - : {}), - ...(reasoningEffort && reasoningEffort !== 'max' && options.reasoning === undefined - ? { reasoning: reasoningEffort } - : {}), - ...(reasoningEffort === 'max' && options.reasoning === undefined - ? { - headers: { - ...options.headers, - [REASONING_EFFORT_HEADER]: reasoningEffort, - }, - } - : {}), - providerOptions, - }; -} - -function projectPromptMessage(message: LanguageModelV4Message): LanguageModelV4Message { - if (message.role !== 'assistant' && message.role !== 'tool') return message; - let changed = false; - const content = message.content.map((part) => { - if (part.type === 'tool-call' && part.toolName === APPLY_PATCH_TOOL_NAME) { - changed = true; - return { - ...part, - toolName: APPLY_PATCH_SENTINEL_NAME, - input: { input: part.input }, - }; - } - if (part.type === 'tool-result' && part.toolName === APPLY_PATCH_TOOL_NAME) { - changed = true; - return { ...part, toolName: APPLY_PATCH_SENTINEL_NAME }; - } - if ( - (part.type === 'tool-call' || part.type === 'tool-result') && - part.toolName === WEB_SEARCH_TOOL_NAME - ) { - changed = true; - return { ...part, toolName: WEB_SEARCH_SENTINEL_NAME }; - } - return part; - }); - return changed ? ({ ...message, content } as LanguageModelV4Message) : message; -} - -function projectTool( - tool: NonNullable[number], -): NonNullable[number] { - if (tool.type !== 'provider') return tool; - if (tool.id !== OPENAI_CUSTOM_TOOL_ID || tool.name !== APPLY_PATCH_TOOL_NAME) { - if (tool.id !== OPENAI_WEB_SEARCH_TOOL_ID || tool.name !== WEB_SEARCH_TOOL_NAME) { - throw new Error(`open_responses_provider_tool_unsupported:${tool.id}:${tool.name}`); - } - return { - type: 'function', - name: WEB_SEARCH_SENTINEL_NAME, - description: JSON.stringify(tool.args), - inputSchema: { - type: 'object', - properties: {}, - additionalProperties: false, - }, - strict: true, - }; - } - return { - type: 'function' as const, - name: APPLY_PATCH_SENTINEL_NAME, - description: 'Apply a patch to the local workspace.', - inputSchema: { - type: 'object' as const, - properties: { input: { type: 'string' as const } }, - required: ['input'], - additionalProperties: false, - }, - strict: true, - }; -} - -function reasoningEffortFrom( - value: unknown, -): LanguageModelV4CallOptions['reasoning'] | 'max' | undefined { - return value === 'none' || - value === 'minimal' || - value === 'low' || - value === 'medium' || - value === 'high' || - value === 'xhigh' || - value === 'max' - ? value - : undefined; -} - -function projectGenerateResult( - result: LanguageModelV4GenerateResult, -): LanguageModelV4GenerateResult { - return { - ...result, - content: result.content.flatMap(projectModelContent), - ...(result.request?.body !== undefined - ? { request: { ...result.request, body: projectRequestBody(result.request.body) } } - : {}), - }; -} - -function projectStreamResult(result: LanguageModelV4StreamResult): LanguageModelV4StreamResult { - return { - ...result, - stream: result.stream.pipeThrough( - new TransformStream({ - transform(part, controller) { - for (const projected of projectStreamPart(part)) controller.enqueue(projected); - }, - }), - ), - ...(result.request?.body !== undefined - ? { request: { ...result.request, body: projectRequestBody(result.request.body) } } - : {}), - }; -} - -function projectModelContent(content: LanguageModelV4Content): LanguageModelV4Content[] { - if (content.type !== 'tool-call') return [content]; - if (content.toolName === WEB_SEARCH_SENTINEL_NAME) { - return projectWebSearchContent(content); - } - if (content.toolName !== APPLY_PATCH_SENTINEL_NAME) { - return [content]; - } - return [ - { - ...content, - toolName: APPLY_PATCH_TOOL_NAME, - input: customToolInputForModel(content.input), - }, - ]; -} - -function projectStreamPart(part: LanguageModelV4StreamPart): LanguageModelV4StreamPart[] { - if (part.type === 'tool-input-start') { - if (part.toolName === APPLY_PATCH_SENTINEL_NAME) { - return [{ ...part, toolName: APPLY_PATCH_TOOL_NAME }]; - } - if (part.toolName === WEB_SEARCH_SENTINEL_NAME) { - return [{ ...part, toolName: WEB_SEARCH_TOOL_NAME, providerExecuted: true }]; - } - } - if (part.type !== 'tool-call') return [part]; - return projectModelContent(part) as LanguageModelV4StreamPart[]; -} - -function projectWebSearchContent( - content: Extract, -): LanguageModelV4Content[] { - let payload: Record = {}; - try { - payload = asRecord(JSON.parse(content.input) as unknown) ?? {}; - } catch { - // A malformed hosted result remains visible as an empty provider result. - } - const result: JSONObject = {}; - if (isJSONValue(payload.action)) result.action = payload.action; - if (isJSONValue(payload.sources)) result.sources = payload.sources; - return [ - { - ...content, - toolName: WEB_SEARCH_TOOL_NAME, - input: '{}', - providerExecuted: true, - }, - { - type: 'tool-result', - toolCallId: content.toolCallId, - toolName: WEB_SEARCH_TOOL_NAME, - result, - }, - ]; -} - -function customToolInputForModel(input: string): string { - try { - const parsed = JSON.parse(input) as unknown; - const record = asRecord(parsed); - if (record && typeof record.input === 'string') return JSON.stringify(record.input); - } catch { - // Preserve malformed provider input so the AI SDK's ordinary validation path owns the error. - } - return input; -} - -function createDialectFetch(fetchImpl: typeof globalThis.fetch): typeof globalThis.fetch { - return async (input, init) => { - const response = await fetchImpl(input, projectRequestInit(init)); - return projectResponse(response); - }; -} - -function projectRequestInit(init: RequestInit | undefined): RequestInit | undefined { - if (!init) return init; - const headers = new Headers(init.headers); - const reasoningEffort = headers.get(REASONING_EFFORT_HEADER); - headers.delete(REASONING_EFFORT_HEADER); - if (typeof init.body !== 'string') { - return reasoningEffort ? { ...init, headers } : init; - } - let body: unknown; - try { - body = JSON.parse(init.body) as unknown; - } catch { - return reasoningEffort ? { ...init, headers } : init; - } - const projected = projectRequestBody(body); - const record = asRecord(projected); - const reasoning = asRecord(record?.reasoning); - return { - ...init, - headers, - body: JSON.stringify( - record && reasoningEffort === 'max' - ? { ...record, reasoning: { ...reasoning, effort: reasoningEffort } } - : projected, - ), - }; -} - -function projectRequestBody(body: unknown): unknown { - const record = asRecord(body); - if (!record) return body; - const customCallIds = new Set(); - const webSearchCallIds = new Set(); - const input = Array.isArray(record.input) - ? record.input.flatMap((item) => { - const itemRecord = asRecord(item); - if (!itemRecord) return [item]; - if (itemRecord.type === 'function_call' && itemRecord.name === APPLY_PATCH_SENTINEL_NAME) { - if (typeof itemRecord.call_id !== 'string') { - throw new Error('open_responses_custom_call_id'); - } - const customInput = customToolInputFromArguments(itemRecord.arguments); - customCallIds.add(itemRecord.call_id); - return [ - { - type: 'custom_tool_call', - call_id: itemRecord.call_id, - name: APPLY_PATCH_TOOL_NAME, - input: customInput, - }, - ]; - } - if (itemRecord.type === 'function_call' && itemRecord.name === WEB_SEARCH_SENTINEL_NAME) { - if (typeof itemRecord.call_id !== 'string') { - throw new Error('open_responses_web_search_call_id'); - } - webSearchCallIds.add(itemRecord.call_id); - return [{ type: 'item_reference', id: itemRecord.call_id }]; - } - if ( - itemRecord.type === 'function_call_output' && - typeof itemRecord.call_id === 'string' && - webSearchCallIds.has(itemRecord.call_id) - ) { - return []; - } - if ( - itemRecord.type === 'function_call_output' && - typeof itemRecord.call_id === 'string' && - customCallIds.has(itemRecord.call_id) - ) { - return [ - { - type: 'custom_tool_call_output', - call_id: itemRecord.call_id, - output: itemRecord.output, - }, - ]; - } - return [item]; - }) - : record.input; - const tools = Array.isArray(record.tools) - ? record.tools.map((tool) => { - const toolRecord = asRecord(tool); - if (toolRecord?.type !== 'function') return tool; - if (toolRecord.name === APPLY_PATCH_SENTINEL_NAME) { - return { type: 'custom', name: APPLY_PATCH_TOOL_NAME }; - } - if (toolRecord.name === WEB_SEARCH_SENTINEL_NAME) { - return projectWebSearchTool(toolRecord.description); - } - return tool; - }) - : record.tools; - return { - ...record, - store: false, - ...(input !== undefined ? { input } : {}), - ...(tools !== undefined ? { tools } : {}), - }; -} - -function customToolInputFromArguments(argumentsValue: unknown): string { - if (typeof argumentsValue !== 'string') throw new Error('open_responses_custom_arguments'); - let parsed: unknown; - try { - parsed = JSON.parse(argumentsValue) as unknown; - } catch { - throw new Error('open_responses_custom_arguments'); - } - const record = asRecord(parsed); - if (!record || typeof record.input !== 'string') { - throw new Error('open_responses_custom_arguments'); - } - return record.input; -} - -function webSearchArgsFromDescription(value: unknown): Record { - if (typeof value !== 'string') return {}; - try { - return asRecord(JSON.parse(value) as unknown) ?? {}; - } catch { - return {}; - } -} - -function projectWebSearchTool(description: unknown): Record { - const args = webSearchArgsFromDescription(description); - const filters = asRecord(args.filters); - const userLocation = asRecord(args.userLocation); - return { - type: 'web_search', - ...(args.searchContextSize === 'low' || - args.searchContextSize === 'medium' || - args.searchContextSize === 'high' - ? { search_context_size: args.searchContextSize } - : {}), - ...(typeof args.externalWebAccess === 'boolean' - ? { external_web_access: args.externalWebAccess } - : {}), - ...(filters - ? { - filters: { - ...(stringArray(filters.allowedDomains) - ? { allowed_domains: filters.allowedDomains } - : {}), - ...(stringArray(filters.blockedDomains) - ? { blocked_domains: filters.blockedDomains } - : {}), - }, - } - : {}), - ...(userLocation ? { user_location: userLocation } : {}), - }; -} - -function projectResponse(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(projectEventStream())); - } - 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(projectJsonResponse(body))); - controller.close(); - }, - }), - ); -} - -function projectJsonResponse(body: string): string { - let payload: unknown; - try { - payload = JSON.parse(body) as unknown; - } catch { - return body; - } - const record = asRecord(payload); - if (!record || !Array.isArray(record.output)) return body; - const output = record.output.map(projectResponseItem); - return JSON.stringify({ ...record, output }); -} - -function projectResponseItem(item: unknown): unknown { - const record = asRecord(item); - if (!record) return item; - if (record.type === 'web_search_call' && typeof record.id === 'string') { - return { - ...record, - type: 'function_call', - call_id: record.id, - name: WEB_SEARCH_SENTINEL_NAME, - arguments: JSON.stringify({ - ...(record.action !== undefined ? { action: record.action } : {}), - ...(record.sources !== undefined ? { sources: record.sources } : {}), - }), - }; - } - if (record.type !== 'custom_tool_call' || record.name !== APPLY_PATCH_TOOL_NAME) { - return item; - } - const { input, ...rest } = record; - return { - ...rest, - type: 'function_call', - name: APPLY_PATCH_SENTINEL_NAME, - arguments: JSON.stringify({ input: typeof input === 'string' ? input : '' }), - }; -} - -function projectEventStream(): 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) { - const projectedLines = projectEventLine(line); - for (const [index, projected] of projectedLines.entries()) { - controller.enqueue( - encoder.encode(`${projected}\n${index < projectedLines.length - 1 ? '\n' : ''}`), - ); - } - } - }, - flush(controller) { - pending += decoder.decode(); - if (!pending) return; - for (const projected of projectEventLine(pending)) { - controller.enqueue(encoder.encode(projected)); - } - }, - }); -} - -function projectEventLine(line: string): string[] { - if (!line.startsWith('data:')) return [line]; - const raw = line.slice('data:'.length).trim(); - if (!raw || raw === '[DONE]') return [line]; - let event: unknown; - try { - event = JSON.parse(raw) as unknown; - } catch { - return [line]; - } - const eventRecord = asRecord(event); - if (!eventRecord) return [line]; - if (eventRecord.type === 'response.custom_tool_call_input.delta') return []; - if ( - (eventRecord.type === 'response.output_item.added' || - eventRecord.type === 'response.output_item.done') && - ((asRecord(eventRecord.item)?.type === 'custom_tool_call' && - asRecord(eventRecord.item)?.name === APPLY_PATCH_TOOL_NAME) || - asRecord(eventRecord.item)?.type === 'web_search_call') - ) { - const customItem = asRecord(eventRecord.item)!; - const item = projectResponseItem(customItem); - const projectedItem = asRecord(item); - const projectedEvent = `data: ${JSON.stringify({ ...eventRecord, item })}`; - if (eventRecord.type !== 'response.output_item.done') return [projectedEvent]; - const argumentsDone = { - type: 'response.function_call_arguments.done', - sequence_number: - typeof eventRecord.sequence_number === 'number' ? eventRecord.sequence_number : 0, - item_id: projectedItem?.id, - output_index: eventRecord.output_index, - call_id: projectedItem?.call_id, - arguments: typeof projectedItem?.arguments === 'string' ? projectedItem.arguments : '{}', - }; - return [`data: ${JSON.stringify(argumentsDone)}`, projectedEvent]; - } - return [line]; -} - -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(); -} - -function asRecord(value: unknown): Record | undefined { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function stringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((item) => typeof item === 'string'); -} diff --git a/packages/runtime/src/provider-urls.ts b/packages/runtime/src/provider-urls.ts index 8a79695616..067caaf98d 100644 --- a/packages/runtime/src/provider-urls.ts +++ b/packages/runtime/src/provider-urls.ts @@ -36,6 +36,14 @@ 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(); +} + function stripTrailing(u: string): string { return u.replace(/\/+$/, ''); } From b68c172de6739e6e0be5fa30851bff58508b997e Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 14 Aug 2026 01:27:48 +0800 Subject: [PATCH 05/12] test(runtime): enforce explicit Responses relay contracts --- ...enai-responses-plaintext-reasoning.test.ts | 38 +++++++++++++++++++ .../src/__tests__/provider-contract-matrix.ts | 15 +++++++- .../__tests__/provider-contract-overrides.ts | 7 +++- packages/runtime/src/model-factory.ts | 4 +- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts b/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts index 016b74205c..d18031d978 100644 --- a/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts +++ b/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts @@ -198,6 +198,44 @@ 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"}', + }, + ); + 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 () => { diff --git a/packages/runtime/src/__tests__/provider-contract-matrix.ts b/packages/runtime/src/__tests__/provider-contract-matrix.ts index 895952a474..ebb8cc149a 100644 --- a/packages/runtime/src/__tests__/provider-contract-matrix.ts +++ b/packages/runtime/src/__tests__/provider-contract-matrix.ts @@ -342,7 +342,7 @@ function wireDimensionCell( }; } if ( - def.runtimeAdapter.kind === 'openai' && + (def.runtimeAdapter.kind === 'openai' || def.runtimeAdapter.kind === 'openai-compatible') && def.runtimeAdapter.apiProtocol === 'openai-responses' ) { return { @@ -369,6 +369,19 @@ function reasoningReplayCell( contract: `${adapter.kind} replays reasoning on its provider-specific per-model wire`, }; } + if ( + adapter.kind === 'openai-compatible' && + adapter.supportsOpenAiResponses === true && + adapter.apiProtocol === 'openai-responses' + ) { + 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/model-factory.ts b/packages/runtime/src/model-factory.ts index 2cd4323e00..cb47ed2cff 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -524,7 +524,9 @@ function buildFamilyWire( return { openai: { store: false, - ...(reasons ? { forceReasoning: true } : {}), + ...(reasons || reasoningReplay.kind === 'openai-responses-encrypted' + ? { forceReasoning: true } + : {}), ...(reasoningEffort ? { reasoningEffort } : {}), }, }; From d99575132581414b65d58490b1f9e0ccce5256c1 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Fri, 14 Aug 2026 09:49:41 +0800 Subject: [PATCH 06/12] fix(runtime): close Open Responses replay gaps --- packages/core/src/provider-registry.ts | 10 +- .../execution-model-composition.test.ts | 77 +++++++++ .../src/server/execution-model-authority.ts | 14 +- .../src/server/execution-model-composition.ts | 6 +- .../src/__tests__/ai-sdk-backend.test.ts | 151 ++++++++++++++---- .../src/__tests__/apply-patch-profile.test.ts | 31 +++- .../src/__tests__/model-adapter.test.ts | 3 + .../__tests__/model-factory-thinking.test.ts | 19 ++- ...enai-responses-plaintext-reasoning.test.ts | 2 +- .../__tests__/responses-wire-contract.test.ts | 26 +-- packages/runtime/src/ai-sdk-backend.ts | 18 ++- packages/runtime/src/apply-patch-profile.ts | 28 +++- packages/runtime/src/model-adapter.ts | 28 ++-- packages/runtime/src/model-factory.ts | 35 ++++ packages/runtime/src/model-runtime.ts | 22 ++- packages/runtime/src/tool-free-model-call.ts | 5 + 16 files changed, 386 insertions(+), 89 deletions(-) diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 1d1f0b81a2..5dd62e4896 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -31,8 +31,10 @@ type ProviderRuntimeAdapterDefinition = includeUsage?: boolean; requireBaseUrl?: boolean; supportsOpenAiResponses?: true; - /** Select the standard plaintext Open Responses dialect instead of OpenAI's extension. */ - responsesDialect?: 'open-responses'; + /** SDK provider used for a Responses wire. */ + responsesAdapter?: 'openai' | 'open-responses'; + /** Stateless reasoning continuation representation used by the Responses wire. */ + responsesReasoningReplay?: 'encrypted-content' | 'plaintext-content'; replayAssistantReasoningAs?: 'reasoning'; replayAssistantReasoningDetails?: true; }; @@ -839,7 +841,9 @@ const providerRegistry = { kind: 'openai-compatible', name: 'provider', supportsOpenAiResponses: true, - responsesDialect: 'open-responses', + applyPatchProtocol: 'codex-v4a-freeform', + responsesAdapter: 'open-responses', + responsesReasoningReplay: 'plaintext-content', }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', 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 3606520dde..2cd7c401fd 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -1957,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 () => { diff --git a/packages/runtime-host/src/server/execution-model-authority.ts b/packages/runtime-host/src/server/execution-model-authority.ts index 67b85695b3..c3b8f155da 100644 --- a/packages/runtime-host/src/server/execution-model-authority.ts +++ b/packages/runtime-host/src/server/execution-model-authority.ts @@ -10,7 +10,7 @@ import { llmCallUsageFields, recordLlmCallStrict, } from '@maka/runtime/telemetry'; -import { buildProviderOptions, getAIModel } from '@maka/runtime/model-factory'; +import { buildModelCallSettings, getAIModel } from '@maka/runtime/model-factory'; import { buildSessionRecapMessages } from '@maka/runtime/session-recap'; import { buildSessionTitlePrompt, @@ -484,6 +484,11 @@ async function runHostAuxiliaryModelCall( | Awaited>; try { result = await readDuringBackendCreation(() => { + const modelCallSettings = buildModelCallSettings( + target.connection, + target.model, + input.header.thinkingLevel, + ); const model = getAIModel({ connection: target.connection, apiKey, @@ -499,16 +504,13 @@ async function runHostAuxiliaryModelCall( ? 'omit' : 'none', abortSignal: input.abortSignal, + ...modelCallSettings, }) : generateToolFreeModelCall({ model, ...request, abortSignal: input.abortSignal, - providerOptions: buildProviderOptions( - target.connection, - target.model, - input.header.thinkingLevel, - ), + ...modelCallSettings, }); }, input.abortSignal); const oauthFailure = readDeferredOAuthFailure?.(); diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index c9604bd6b0..d5958cef3a 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -13,7 +13,7 @@ import { import { buildLlmHistorySummarizer } from '@maka/runtime/history-compact-summarizer'; import { buildOpenAiCodexHistoryCompactor } from '@maka/runtime/openai-codex-history-compactor'; import { buildPricingLookup, recordToolInvocation } from '@maka/runtime/telemetry'; -import { buildProviderOptions, getAIModel } from '@maka/runtime/model-factory'; +import { buildModelCallSettings, getAIModel } from '@maka/runtime/model-factory'; import { createProviderRequestCaptureRecorder } from '@maka/runtime/provider-request-telemetry'; import { createProxiedFetchTransport, @@ -126,11 +126,11 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom throw error; } } - const providerOptions = buildProviderOptions( + const providerOptions = buildModelCallSettings( target.connection, target.model, input.context.header.thinkingLevel, - ); + ).providerOptions; const contextWindow = resolveSelectedModelContextWindow(target.connection, target.model); let modelComposition: HostRunComposer; try { diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 044e6bb83b..5ad47207ae 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -217,7 +217,7 @@ describe('AiSdkBackend ApplyPatch routing', () => { }); }); - test('preserves a durable DeepSeek apply_patch fact without inventing a custom wire', async () => { + test('downgrades durable DeepSeek freeform apply_patch history to a fact', async () => { const model = completionModel(); const backend = createTestAiSdkBackend({ sessionId: 'session-1', @@ -259,14 +259,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,24 +285,25 @@ 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'); - assert.deepEqual(toolCall?.input, { - callId: 'call-1', - operation: { - type: 'update_file', - path: 'file.txt', - diff: '@@\n-before\n+after', - }, - }); - assert.deepEqual(toolResult?.output, { - type: 'json', - value: { status: 'completed', output: 'Applied 1 file operation.' }, - }); + const replay = compactPrompt(model) as Array<{ role: string; content: any[] }>; + assert.equal( + 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('preserves a multi-file ApplyPatch fact when structured replay cannot represent it', async () => { @@ -3010,6 +3011,90 @@ 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('replays an image tool result as provider image data', async () => { const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]); const model = completionModel(); @@ -12562,6 +12647,14 @@ describe('AiSdkBackend thinking persistence', () => { 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', @@ -12607,8 +12700,8 @@ describe('AiSdkBackend thinking persistence', () => { ); assert.ok(assistant && Array.isArray(assistant.content)); assert.deepEqual( - assistant.content.find((part) => part.type === 'reasoning'), - { type: 'reasoning', text: 'reasoning about the tool', providerOptions: undefined }, + assistant.content.filter((part) => part.type === 'reasoning'), + [{ type: 'reasoning', text: 'reasoning about the tool', providerOptions: undefined }], ); assert.ok( assistant.content.some((part) => part.type === 'tool-call' && part.toolCallId === 'tool-1'), diff --git a/packages/runtime/src/__tests__/apply-patch-profile.test.ts b/packages/runtime/src/__tests__/apply-patch-profile.test.ts index a0772eef7a..3c9a3e9077 100644 --- a/packages/runtime/src/__tests__/apply-patch-profile.test.ts +++ b/packages/runtime/src/__tests__/apply-patch-profile.test.ts @@ -50,9 +50,24 @@ describe('ApplyPatch profile routing', () => { }); test('selects Codex V4A freeform for declared DeepSeek V4 Responses models', () => { + assert.equal( + resolveApplyPatchProfile( + { + wire: 'openai-responses', + responsesAdapter: 'open-responses', + applyPatchProtocol: 'codex-v4a-freeform', + }, + 'deepseek-v4-flash', + ), + null, + ); assert.deepEqual( resolveApplyPatchProfile( - { wire: 'openai-responses', applyPatchProtocol: 'codex-v4a-freeform' }, + { + wire: 'openai-responses', + responsesAdapter: 'openai', + applyPatchProtocol: 'codex-v4a-freeform', + }, 'deepseek-v4-flash', ), { kind: 'codex-v4a-freeform' }, @@ -66,7 +81,11 @@ describe('ApplyPatch profile routing', () => { ); assert.deepEqual( resolveApplyPatchProfile( - { wire: 'openai-responses', applyPatchProtocol: 'codex-v4a-freeform' }, + { + wire: 'openai-responses', + responsesAdapter: 'openai', + applyPatchProtocol: 'codex-v4a-freeform', + }, 'deepseek-v4-pro', ), { kind: 'codex-v4a-freeform' }, @@ -100,6 +119,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' }, diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 8b3255ace8..40d6399541 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -65,6 +65,7 @@ describe('ModelAdapter stream and error normalization', () => { assert.deepEqual(adapter.runtimeEventReplaySupport(), { toolCalls: true, toolResults: true, + providerExecutedTools: true, signedThinking: false, unsignedThinking: true, responsesThinking: 'none', @@ -119,6 +120,7 @@ describe('ModelAdapter stream and error normalization', () => { assert.deepEqual(adapter.runtimeEventReplaySupport(), { toolCalls: true, toolResults: true, + providerExecutedTools: true, signedThinking: false, unsignedThinking: false, responsesThinking: 'openai-encrypted', @@ -142,6 +144,7 @@ describe('ModelAdapter stream and error normalization', () => { assert.deepEqual(adapter.runtimeEventReplaySupport(), { toolCalls: true, toolResults: true, + providerExecutedTools: false, signedThinking: false, unsignedThinking: false, responsesThinking: 'open-responses-plaintext', diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 7d2bf6317c..d727e04050 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 { buildModelCallSettings, buildProviderOptions, getAIModel } from '../model-factory.js'; function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmConnection { return { @@ -203,11 +203,24 @@ describe('buildProviderOptions: thinking level', () => { [...thinkingVariantsForModel('deepseek', 'deepseek-v4-flash')], ['high', 'max'], ); - // DeepSeek V4 speaks the plaintext Open Responses dialect. Its effort is - // a top-level AI SDK option, not providerOptions owned by Maka. + // DeepSeek V4 uses the generic Open Responses adapter. Its effort is a + // top-level AI SDK option, not providerOptions owned by Maka. assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'high'), {}); assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'max'), {}); assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'off'), {}); + assert.deepEqual(buildModelCallSettings(conn('deepseek'), 'deepseek-v4-flash', 'high'), { + providerOptions: {}, + reasoning: 'high', + }); + assert.deepEqual(buildModelCallSettings(conn('deepseek'), 'deepseek-v4-flash', 'max'), { + providerOptions: {}, + reasoning: 'xhigh', + }); + for (const unsupported of ['off', 'low', 'medium', 'minimal'] as const) { + assert.deepEqual(buildModelCallSettings(conn('deepseek'), 'deepseek-v4-flash', unsupported), { + providerOptions: {}, + }); + } 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 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 d18031d978..13651bd594 100644 --- a/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts +++ b/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import type { LlmConnection } from '@maka/core/llm-connections'; -import { buildProviderOptions, getAIModel } from '@maka/runtime/model-factory'; +import { buildProviderOptions, getAIModel } from '../model-factory.js'; function conn(providerType: LlmConnection['providerType']): LlmConnection { return { diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index d4ebae1c1e..ac9684b87f 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -5,7 +5,7 @@ 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 { buildModelCallSettings, 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'; @@ -314,10 +314,10 @@ describe('responses wire request body', () => { }); test('DeepSeek uses plaintext Responses options without asking for encrypted content', async () => { - let body: Record | undefined; + const bodies: Record[] = []; let headers: Headers | undefined; const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { - body = JSON.parse(String(init?.body)); + bodies.push(JSON.parse(String(init?.body)) as Record); headers = new Headers(init?.headers); return new Response( JSON.stringify({ @@ -338,16 +338,18 @@ describe('responses wire request body', () => { modelId: 'deepseek-v4-flash', fetch, }); - await model.doGenerate({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], - reasoning: 'xhigh', - providerOptions: buildProviderOptions(connection, 'deepseek-v4-flash', 'max'), - }); + for (const level of ['high', 'max'] as const) { + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + ...buildModelCallSettings(connection, 'deepseek-v4-flash', level), + }); + } - assert.equal(body?.store, undefined); - assert.equal(body?.include, undefined); - assert.equal((body?.reasoning as { effort?: string } | undefined)?.effort, 'xhigh'); - assert.equal(headers?.has('x-maka-open-responses-reasoning-effort'), false); + assert.equal(bodies[0]?.store, undefined); + assert.equal(bodies[0]?.include, undefined); + assert.equal((bodies[0]?.reasoning as { effort?: string } | undefined)?.effort, 'high'); + assert.equal((bodies[1]?.reasoning as { effort?: string } | undefined)?.effort, 'xhigh'); + assert.equal(headers?.get('authorization'), 'Bearer test-key'); }); test('replays plaintext reasoning before its function call and result', async () => { diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index a8652d51e7..481a0948df 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 { buildModelCallSettings } from './model-factory.js'; import { persistedOpenAiResponsesStepMessages } from './openai-responses-continuation.js'; import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js'; import { @@ -1083,14 +1084,19 @@ export class AiSdkBackend implements AgentBackend { this.now = input.now ?? (() => Date.now()); this.maxSteps = input.maxSteps; this.providerRetrySleep = input.providerRetrySleep ?? sleepForProviderRetry; + const modelCallSettings = buildModelCallSettings( + 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, - reasoningLevel: input.header.thinkingLevel, + providerOptions: input.providerOptions ?? modelCallSettings.providerOptions, + reasoning: modelCallSettings.reasoning, newId: this.newId, now: this.now, ...(input.openAiResponsesTransportState @@ -3866,6 +3872,13 @@ 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; @@ -3932,6 +3945,7 @@ export class AiSdkBackend implements AgentBackend { : undefined; } if (replaySupport.responsesThinking === 'open-responses-plaintext') { + if (item.text.length === 0) return undefined; return { part: { type: 'reasoning' as const, diff --git a/packages/runtime/src/apply-patch-profile.ts b/packages/runtime/src/apply-patch-profile.ts index 80dca16c30..383d84ced9 100644 --- a/packages/runtime/src/apply-patch-profile.ts +++ b/packages/runtime/src/apply-patch-profile.ts @@ -3,7 +3,7 @@ import { deepSeekModelSupportsResponses } from '@maka/core/model-metadata'; import { z } from 'zod'; import { parseCodexV4aPatch, serializeCodexV4aOperation } from './codex-v4a-patch.js'; import type { ApplyPatchOperation } from './filesystem-executor.js'; -import type { ModelRuntimeWire } from './model-runtime.js'; +import type { ModelRuntimeWire, ResponsesAdapter } from './model-runtime.js'; import { openAiModelSupportsApplyPatch } from './openai-apply-patch.js'; import type { MakaTool } from './tool-runtime.js'; @@ -13,6 +13,7 @@ export type ApplyPatchProfile = export interface ApplyPatchProfileRuntime { readonly wire: ModelRuntimeWire; + readonly responsesAdapter?: ResponsesAdapter; readonly applyPatchProtocol?: ApplyPatchProtocol; } @@ -22,6 +23,11 @@ export function resolveApplyPatchProfile( modelId: string, ): ApplyPatchProfile | null { if (runtime.wire !== 'openai-responses' || !runtime.applyPatchProtocol) return null; + // The generic Open Responses SDK adapter cannot serialize provider-defined + // custom tools yet. Keep the provider capability declared in the registry, + // but fail closed at the active codec boundary so it returns automatically + // when that adapter gains custom-tool support. + if (runtime.responsesAdapter === 'open-responses') return null; const id = modelId.trim().toLowerCase(); if (runtime.applyPatchProtocol === 'openai-structured' && openAiModelSupportsApplyPatch(id)) { return { kind: 'openai-structured' }; @@ -73,7 +79,10 @@ export function normalizeApplyPatchReplayInput( toolCallId: string, input: unknown, ): unknown | null { - if (!profile) return input; + // 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 (profile.kind === 'codex-v4a-freeform') { if (typeof input === 'string') return input; const operation = structuredApplyPatchOperation(input); @@ -94,12 +103,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/model-adapter.ts b/packages/runtime/src/model-adapter.ts index c584473d74..4426ec641c 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -6,7 +6,7 @@ import { type RuntimeExecutionConnection, } from '@maka/core/llm-connections'; import { lookupModelMetadata } from '@maka/core/model-metadata'; -import type { ThinkingLevel } from '@maka/core/model-thinking'; +import type { LanguageModelV4CallOptions } from '@ai-sdk/provider'; import { generalizedErrorMessage } from '@maka/core/redaction'; import type { CacheMissInputSource } from '@maka/core/usage-stats/types'; import { rawFinishReasonString } from './model-protocol.js'; @@ -96,8 +96,8 @@ export interface ModelAdapterInput { modelId: string; modelFactory: ModelFactory; providerOptions?: Record; - /** Session-selected effort; Open Responses consumes it at the top-level seam. */ - reasoningLevel?: ThinkingLevel; + /** Resolved top-level reasoning option for adapters that consume it outside providerOptions. */ + reasoning?: LanguageModelV4CallOptions['reasoning']; newId: () => string; now: () => number; /** Test seam; production adapters own one state instance for their lifetime. */ @@ -182,6 +182,10 @@ export class ModelAdapter { return { toolCalls: true, toolResults: true, + // @ai-sdk/open-responses@2.0.27 drops provider-executed results while + // retaining their calls during replay. Fail closed until the released + // codec can preserve the complete hosted-tool item sequence. + providerExecutedTools: this.runtime.responsesAdapter !== '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 @@ -278,10 +282,6 @@ export class ModelAdapter { continuation.previousResponseId, ) : this.input.providerOptions; - const reasoning = - this.runtime.reasoningReplay.kind === 'open-responses-plaintext' - ? openResponsesReasoning(this.input.reasoningLevel) - : undefined; const sdkResult = streamText({ model: trackedModel, messages: continuation.messages, @@ -298,7 +298,7 @@ export class ModelAdapter { ...(input.system ? { instructions: input.system } : {}), ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), providerOptions, - ...(reasoning ? { reasoning } : {}), + ...(this.input.reasoning !== undefined ? { reasoning: this.input.reasoning } : {}), ...(responsesLane ? { headers: { [OPENAI_RESPONSES_LANE_HEADER]: responsesLane } } : {}), maxRetries: 0, // Preserve the final request's Maka-owned message projection without @@ -526,17 +526,6 @@ export class ModelAdapter { } } -function openResponsesReasoning( - level: ThinkingLevel | undefined, -): 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | undefined { - if (!level) return undefined; - if (level === 'off') return 'none'; - // @ai-sdk/open-responses has no `max` level. Keep this an explicit - // compatibility mapping instead of rewriting the provider request body. - if (level === 'max') return 'xhigh'; - return level; -} - interface ModelStepSettlementEvidence { aborted: boolean; failure?: ModelFailure; @@ -656,6 +645,7 @@ function fixedAnthropicThinkingBudget( export interface ModelAdapterRuntimeEventReplaySupport { toolCalls: boolean; toolResults: boolean; + providerExecutedTools: boolean; signedThinking: boolean; unsignedThinking: boolean; responsesThinking: 'none' | 'openai-encrypted' | 'open-responses-plaintext'; diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index cb47ed2cff..188f201efa 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -8,6 +8,7 @@ import { isJSONArray, type JSONArray, type LanguageModelV4, + type LanguageModelV4CallOptions, type LanguageModelV4StreamPart, type SharedV4ProviderMetadata, type SharedV4ProviderOptions, @@ -502,6 +503,40 @@ export function buildProviderOptions( } } +export interface ModelCallSettings { + readonly providerOptions: SharedV4ProviderOptions; + readonly reasoning?: LanguageModelV4CallOptions['reasoning']; +} + +/** Resolve every reasoning-related AI SDK call option through one provider/model seam. */ +export function buildModelCallSettings( + connection: RuntimeExecutionConnection, + modelId: string, + thinkingLevel?: ThinkingLevel, +): ModelCallSettings { + const providerOptions = buildProviderOptions(connection, modelId, thinkingLevel); + const runtime = resolveModelRuntime(connection, modelId); + const level = resolveThinkingLevel(connection, modelId, thinkingLevel); + const reasoning = + runtime.responsesAdapter === 'open-responses' ? openResponsesReasoning(level) : undefined; + return { + providerOptions, + ...(reasoning !== undefined ? { reasoning } : {}), + }; +} + +function openResponsesReasoning( + level: ThinkingLevel | undefined, +): LanguageModelV4CallOptions['reasoning'] { + if (!level) return undefined; + if (level === 'off') return 'none'; + // The cross-provider AI SDK enum has no vendor-specific `max`. `xhigh` + // keeps reasoning enabled but DeepSeek maps it to high, not max; the PR + // description records that temporary upstream limitation. + if (level === 'max') return 'xhigh'; + return level; +} + function buildFamilyWire( connection: RuntimeExecutionConnection, modelId: string, diff --git a/packages/runtime/src/model-runtime.ts b/packages/runtime/src/model-runtime.ts index a98fd35de6..5b5a5a435b 100644 --- a/packages/runtime/src/model-runtime.ts +++ b/packages/runtime/src/model-runtime.ts @@ -22,6 +22,8 @@ export type ReasoningReplayContract = | { kind: 'openai-responses-encrypted' } | { kind: 'open-responses-plaintext' }; +export type ResponsesAdapter = 'openai' | 'open-responses'; + export interface ResolvedModelRuntime { adapter: ProviderRuntimeAdapter; baseUrl: string; @@ -29,6 +31,8 @@ export interface ResolvedModelRuntime { apiProtocol?: ModelInfo['apiProtocol']; /** Effective wire after account, adapter, and model defaults are resolved. */ wire: ModelRuntimeWire; + /** SDK provider selected for a Responses wire. */ + responsesAdapter?: ResponsesAdapter; /** Durable reasoning replay semantics carried by that wire. */ reasoningReplay: ReasoningReplayContract; /** Effective ApplyPatch contract after provider, model, and request wire are resolved. */ @@ -89,9 +93,16 @@ export function resolveModelRuntime( : resolvedBaseUrl, ...(apiProtocol ? { apiProtocol } : {}), wire, + ...(wire === 'openai-responses' ? { responsesAdapter: responsesAdapterContract(adapter) } : {}), reasoningReplay: reasoningReplayContract(adapter, wire), applyPatchProfile: resolveApplyPatchProfile( - { wire, applyPatchProtocol: adapter.applyPatchProtocol }, + { + wire, + ...(wire === 'openai-responses' + ? { responsesAdapter: responsesAdapterContract(adapter) } + : {}), + applyPatchProtocol: adapter.applyPatchProtocol, + }, modelId, ), }; @@ -159,7 +170,8 @@ function reasoningReplayContract( case 'anthropic-messages': return { kind: 'anthropic-signed' }; case 'openai-responses': - return adapter.kind === 'openai-compatible' && adapter.responsesDialect === 'open-responses' + return adapter.kind === 'openai-compatible' && + adapter.responsesReasoningReplay === 'plaintext-content' ? { kind: 'open-responses-plaintext' } : { kind: 'openai-responses-encrypted' }; case 'openai-chat': @@ -176,6 +188,12 @@ function reasoningReplayContract( } } +function responsesAdapterContract(adapter: ProviderRuntimeAdapter): ResponsesAdapter { + return adapter.kind === 'openai-compatible' && adapter.responsesAdapter === 'open-responses' + ? 'open-responses' + : 'openai'; +} + function kimiOpenAiBaseUrl(baseUrl: string): string { return `${baseUrl.replace(/\/+$/, '').replace(/\/v1$/i, '')}/v1`; } diff --git a/packages/runtime/src/tool-free-model-call.ts b/packages/runtime/src/tool-free-model-call.ts index 1c51db8cb3..a5fc3bc27c 100644 --- a/packages/runtime/src/tool-free-model-call.ts +++ b/packages/runtime/src/tool-free-model-call.ts @@ -1,3 +1,4 @@ +import type { LanguageModelV4CallOptions } from '@ai-sdk/provider'; import type { ModelMessage } from './model-protocol.js'; import { lowerModelTools, normalizeAiSdkUsage, type AiSdkUsageLike } from './model-adapter.js'; import { rawFinishReasonString, type NormalizedUsage } from './model-protocol.js'; @@ -12,6 +13,7 @@ export type ToolFreeModelCallInput = ToolFreeModelCallContent & { /** Optional original Agent system prefix for cache-compatible auxiliary calls. */ readonly system?: string; readonly providerOptions?: unknown; + readonly reasoning?: LanguageModelV4CallOptions['reasoning']; readonly abortSignal?: AbortSignal; readonly maxOutputTokens: number; readonly maxRetries?: number; @@ -30,6 +32,7 @@ export interface ProviderPrefixModelCallInput { readonly tools: ModelToolSet; readonly activeTools: readonly string[]; readonly providerOptions?: unknown; + readonly reasoning?: LanguageModelV4CallOptions['reasoning']; readonly abortSignal?: AbortSignal; readonly maxOutputTokens?: number; /** Anthropic omits Tool schemas when AI SDK receives `none`; omit there and fail closed below. */ @@ -76,6 +79,7 @@ export async function generateProviderPrefixModelCall( ...(input.toolChoicePolicy === 'none' ? { toolChoice: 'none' } : {}), ...(input.abortSignal === undefined ? {} : { abortSignal: input.abortSignal }), ...(input.providerOptions === undefined ? {} : { providerOptions: input.providerOptions }), + ...(input.reasoning === undefined ? {} : { reasoning: input.reasoning }), ...(input.maxOutputTokens === undefined ? {} : { maxOutputTokens: input.maxOutputTokens }), maxRetries: 0, }); @@ -108,6 +112,7 @@ export async function generateToolFreeModelCall( ...(input.prompt === undefined ? { messages: input.messages } : { prompt: input.prompt }), ...(input.abortSignal === undefined ? {} : { abortSignal: input.abortSignal }), ...(input.providerOptions === undefined ? {} : { providerOptions: input.providerOptions }), + ...(input.reasoning === undefined ? {} : { reasoning: input.reasoning }), maxOutputTokens: input.maxOutputTokens, ...(input.maxRetries === undefined ? {} : { maxRetries: input.maxRetries }), }); From c6bb64394a977bb19d52ec0aca1504d84c78bdaa Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 15 Aug 2026 11:44:45 +0800 Subject: [PATCH 07/12] feat(runtime): pass DeepSeek reasoning effort verbatim on Open Responses @ai-sdk/open-responses@2.0.28 resolves a provider-native reasoningEffort from providerOptions ahead of the cross-provider top-level `reasoning` enum, which cannot express DeepSeek's `max` (its documented mapping sends `xhigh` to high). Key the open-responses providerOptions namespace by the same provider name passed to createOpenResponses so `max` reaches the wire unchanged, and fold the top-level reasoning channel into buildProviderOptions as the single reasoning seam. --- .../licenses/npm/THIRD_PARTY_NOTICES.txt | 252 +----------------- package-lock.json | 8 +- .../src/server/execution-model-authority.ts | 8 +- .../src/server/execution-model-composition.ts | 6 +- packages/runtime/package.json | 2 +- .../src/__tests__/ai-sdk-backend.test.ts | 4 +- .../__tests__/model-factory-thinking.test.ts | 37 +-- ...enai-responses-plaintext-reasoning.test.ts | 4 +- .../__tests__/responses-wire-contract.test.ts | 8 +- packages/runtime/src/ai-sdk-backend.ts | 11 +- packages/runtime/src/model-adapter.ts | 12 +- packages/runtime/src/model-factory.ts | 61 ++--- packages/runtime/src/tool-free-model-call.ts | 5 - 13 files changed, 73 insertions(+), 345 deletions(-) diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 8607af26af..41a5fa0a5d 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -119,10 +119,7 @@ limitations under the License. ================================================================================ -<<<<<<< HEAD -Package: @ai-sdk/openai@4.0.42 -======= -Package: @ai-sdk/open-responses@2.0.27 +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 @@ -144,8 +141,7 @@ limitations under the License. ================================================================================ -Package: @ai-sdk/openai@4.0.36 ->>>>>>> be395bd54 (chore(licenses): record open-responses dependencies) +Package: @ai-sdk/openai@4.0.42 Declared license: Apache-2.0 Selected license: Apache-2.0 Repository: https://github.com/vercel/ai#packages/openai @@ -561,250 +557,6 @@ OTHER DEALINGS IN THE FONT SOFTWARE. ================================================================================ -Package: @ai-sdk/provider-utils@5.0.27 -Declared license: Apache-2.0 -Selected license: Apache-2.0 -Repository: https://github.com/vercel/ai#packages/provider-utils - ---- VERSION-PINNED LICENSE TEXT OVERRIDE --- -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - 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. - -THIRD-PARTY COMPONENTS - -trycua/cua cursor-overlay - -Source: https://github.com/trycua/cua -Revision: 8c921b2b3bf13494724ead4f0a814d80c56a7e8b -Copyright (c) 2025 Cua AI, Inc. -License: MIT - -Maka's agent-cursor renderer and palette include adaptations of this component. -The following MIT License applies to that material: - -MIT License - -Copyright (c) 2025 Cua AI, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -================================================================================ - Package: @antfu/install-pkg@1.1.0 Declared license: MIT Selected license: MIT diff --git a/package-lock.json b/package-lock.json index efc9f3fbf1..b36aab0132 100644 --- a/package-lock.json +++ b/package-lock.json @@ -172,9 +172,9 @@ } }, "node_modules/@ai-sdk/open-responses": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/@ai-sdk/open-responses/-/open-responses-2.0.27.tgz", - "integrity": "sha512-1SUYWyJrtmYXokeeoLdNNWdacUOVtO6eXInCj2imIB59fcOXGuUdoHyZ2oF0TGZ1qCHfzoZ0f1elpTMHoXAtdA==", + "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", @@ -13704,7 +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.27", + "@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/runtime-host/src/server/execution-model-authority.ts b/packages/runtime-host/src/server/execution-model-authority.ts index c3b8f155da..33457c30e8 100644 --- a/packages/runtime-host/src/server/execution-model-authority.ts +++ b/packages/runtime-host/src/server/execution-model-authority.ts @@ -10,7 +10,7 @@ import { llmCallUsageFields, recordLlmCallStrict, } from '@maka/runtime/telemetry'; -import { buildModelCallSettings, getAIModel } from '@maka/runtime/model-factory'; +import { buildProviderOptions, getAIModel } from '@maka/runtime/model-factory'; import { buildSessionRecapMessages } from '@maka/runtime/session-recap'; import { buildSessionTitlePrompt, @@ -484,7 +484,7 @@ async function runHostAuxiliaryModelCall( | Awaited>; try { result = await readDuringBackendCreation(() => { - const modelCallSettings = buildModelCallSettings( + const providerOptions = buildProviderOptions( target.connection, target.model, input.header.thinkingLevel, @@ -504,13 +504,13 @@ async function runHostAuxiliaryModelCall( ? 'omit' : 'none', abortSignal: input.abortSignal, - ...modelCallSettings, + providerOptions, }) : generateToolFreeModelCall({ model, ...request, abortSignal: input.abortSignal, - ...modelCallSettings, + providerOptions, }); }, input.abortSignal); const oauthFailure = readDeferredOAuthFailure?.(); diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index d5958cef3a..c9604bd6b0 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -13,7 +13,7 @@ import { import { buildLlmHistorySummarizer } from '@maka/runtime/history-compact-summarizer'; import { buildOpenAiCodexHistoryCompactor } from '@maka/runtime/openai-codex-history-compactor'; import { buildPricingLookup, recordToolInvocation } from '@maka/runtime/telemetry'; -import { buildModelCallSettings, getAIModel } from '@maka/runtime/model-factory'; +import { buildProviderOptions, getAIModel } from '@maka/runtime/model-factory'; import { createProviderRequestCaptureRecorder } from '@maka/runtime/provider-request-telemetry'; import { createProxiedFetchTransport, @@ -126,11 +126,11 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom throw error; } } - const providerOptions = buildModelCallSettings( + const providerOptions = buildProviderOptions( target.connection, target.model, input.context.header.thinkingLevel, - ).providerOptions; + ); const contextWindow = resolveSelectedModelContextWindow(target.connection, target.model); let modelComposition: HostRunComposer; try { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 202a6f5e0b..e7ce3efa87 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -127,7 +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.27", + "@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 5ad47207ae..5255763fd6 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -12708,7 +12708,7 @@ describe('AiSdkBackend thinking persistence', () => { ); }); - test('maps DeepSeek max reasoning to the upstream Open Responses xhigh level', async () => { + 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; @@ -12751,7 +12751,7 @@ describe('AiSdkBackend thinking persistence', () => { await drain(backend.send({ turnId: 'turn-current', text: 'think', context: [] })); - assert.deepEqual(requestBody?.reasoning, { effort: 'xhigh' }); + assert.deepEqual(requestBody?.reasoning, { effort: 'max' }); assert.equal(requestBody?.include, undefined); }); diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index d727e04050..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 { buildModelCallSettings, buildProviderOptions, getAIModel } from '../model-factory.js'; +import { buildProviderOptions, getAIModel } from '../model-factory.js'; function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmConnection { return { @@ -203,23 +203,20 @@ describe('buildProviderOptions: thinking level', () => { [...thinkingVariantsForModel('deepseek', 'deepseek-v4-flash')], ['high', 'max'], ); - // DeepSeek V4 uses the generic Open Responses adapter. Its effort is a - // top-level AI SDK option, not providerOptions owned by Maka. - assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'high'), {}); - assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'max'), {}); - assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'off'), {}); - assert.deepEqual(buildModelCallSettings(conn('deepseek'), 'deepseek-v4-flash', 'high'), { - providerOptions: {}, - reasoning: 'high', - }); - assert.deepEqual(buildModelCallSettings(conn('deepseek'), 'deepseek-v4-flash', 'max'), { - providerOptions: {}, - reasoning: 'xhigh', + // 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'), { + deepseek: { reasoningEffort: 'high' }, + }); + assert.deepEqual(buildProviderOptions(conn('deepseek'), 'deepseek-v4-flash', 'max'), { + deepseek: { reasoningEffort: 'max' }, }); for (const unsupported of ['off', 'low', 'medium', 'minimal'] as const) { - assert.deepEqual(buildModelCallSettings(conn('deepseek'), 'deepseek-v4-flash', unsupported), { - providerOptions: {}, - }); + 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')], []); @@ -503,7 +500,7 @@ describe('buildProviderOptions: openai-compatible namespace', () => { { 'zai-coding-plan': { reasoningEffort: 'max' } }, ); }); - test('deepseek uses provider options only for the chat dialect', () => { + 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' }], @@ -511,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'), - {}, + { + deepseek: { reasoningEffort: 'high' }, + }, ); }); 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 13651bd594..62e92d15c9 100644 --- a/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts +++ b/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts @@ -230,7 +230,9 @@ describe('open responses plaintext reasoning', () => { type: 'tool-call', toolCallId: 'call_1', toolName: 'Read', - input: '{"path":"package.json"}', + 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'); diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index ac9684b87f..18677e12d4 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -5,7 +5,7 @@ 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 { buildModelCallSettings, buildProviderOptions, getAIModel } from '../model-factory.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'; @@ -341,14 +341,16 @@ describe('responses wire request body', () => { for (const level of ['high', 'max'] as const) { await model.doGenerate({ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], - ...buildModelCallSettings(connection, 'deepseek-v4-flash', level), + 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'); - assert.equal((bodies[1]?.reasoning as { effort?: string } | undefined)?.effort, 'xhigh'); + // 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'); }); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 481a0948df..bafc816994 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -138,7 +138,7 @@ import { type ModelStreamResult, type RepairableAiSdkToolCall, } from './model-adapter.js'; -import { buildModelCallSettings } from './model-factory.js'; +import { buildProviderOptions } from './model-factory.js'; import { persistedOpenAiResponsesStepMessages } from './openai-responses-continuation.js'; import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js'; import { @@ -1084,7 +1084,7 @@ export class AiSdkBackend implements AgentBackend { this.now = input.now ?? (() => Date.now()); this.maxSteps = input.maxSteps; this.providerRetrySleep = input.providerRetrySleep ?? sleepForProviderRetry; - const modelCallSettings = buildModelCallSettings( + const modelCallProviderOptions = buildProviderOptions( input.connection, input.modelId, input.header.thinkingLevel, @@ -1095,8 +1095,11 @@ export class AiSdkBackend implements AgentBackend { apiKey: input.apiKey, modelId: input.modelId, modelFactory: input.modelFactory, - providerOptions: input.providerOptions ?? modelCallSettings.providerOptions, - reasoning: modelCallSettings.reasoning, + // `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: input.providerOptions ?? modelCallProviderOptions, newId: this.newId, now: this.now, ...(input.openAiResponsesTransportState diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 4426ec641c..2438cf0219 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -6,7 +6,6 @@ import { type RuntimeExecutionConnection, } from '@maka/core/llm-connections'; import { lookupModelMetadata } from '@maka/core/model-metadata'; -import type { LanguageModelV4CallOptions } from '@ai-sdk/provider'; import { generalizedErrorMessage } from '@maka/core/redaction'; import type { CacheMissInputSource } from '@maka/core/usage-stats/types'; import { rawFinishReasonString } from './model-protocol.js'; @@ -96,8 +95,6 @@ export interface ModelAdapterInput { modelId: string; modelFactory: ModelFactory; providerOptions?: Record; - /** Resolved top-level reasoning option for adapters that consume it outside providerOptions. */ - reasoning?: LanguageModelV4CallOptions['reasoning']; newId: () => string; now: () => number; /** Test seam; production adapters own one state instance for their lifetime. */ @@ -182,9 +179,11 @@ export class ModelAdapter { return { toolCalls: true, toolResults: true, - // @ai-sdk/open-responses@2.0.27 drops provider-executed results while - // retaining their calls during replay. Fail closed until the released - // codec can preserve the complete hosted-tool item sequence. + // 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.responsesAdapter !== 'open-responses', signedThinking: this.runtime.reasoningReplay.kind === 'anthropic-signed', // openai-compatible transports replay stored reasoning unconditionally: @@ -298,7 +297,6 @@ export class ModelAdapter { ...(input.system ? { instructions: input.system } : {}), ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), providerOptions, - ...(this.input.reasoning !== undefined ? { reasoning: this.input.reasoning } : {}), ...(responsesLane ? { headers: { [OPENAI_RESPONSES_LANE_HEADER]: responsesLane } } : {}), maxRetries: 0, // Preserve the final request's Maka-owned message projection without diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 188f201efa..a6396f675e 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -8,7 +8,6 @@ import { isJSONArray, type JSONArray, type LanguageModelV4, - type LanguageModelV4CallOptions, type LanguageModelV4StreamPart, type SharedV4ProviderMetadata, type SharedV4ProviderOptions, @@ -503,58 +502,34 @@ export function buildProviderOptions( } } -export interface ModelCallSettings { - readonly providerOptions: SharedV4ProviderOptions; - readonly reasoning?: LanguageModelV4CallOptions['reasoning']; -} - -/** Resolve every reasoning-related AI SDK call option through one provider/model seam. */ -export function buildModelCallSettings( - connection: RuntimeExecutionConnection, - modelId: string, - thinkingLevel?: ThinkingLevel, -): ModelCallSettings { - const providerOptions = buildProviderOptions(connection, modelId, thinkingLevel); - const runtime = resolveModelRuntime(connection, modelId); - const level = resolveThinkingLevel(connection, modelId, thinkingLevel); - const reasoning = - runtime.responsesAdapter === 'open-responses' ? openResponsesReasoning(level) : undefined; - return { - providerOptions, - ...(reasoning !== undefined ? { reasoning } : {}), - }; -} - -function openResponsesReasoning( - level: ThinkingLevel | undefined, -): LanguageModelV4CallOptions['reasoning'] { - if (!level) return undefined; - if (level === 'off') return 'none'; - // The cross-provider AI SDK enum has no vendor-specific `max`. `xhigh` - // keeps reasoning enabled but DeepSeek maps it to high, not max; the PR - // description records that temporary upstream limitation. - if (level === 'max') return 'xhigh'; - return level; -} - function buildFamilyWire( connection: RuntimeExecutionConnection, modelId: string, level: ThinkingLevel | undefined, thinkingOptions: ThinkingOptions | undefined, ): SharedV4ProviderOptions { - const { adapter, wire, reasoningReplay } = resolveModelRuntime(connection, modelId); + const { adapter, wire, responsesAdapter, reasoningReplay } = resolveModelRuntime( + connection, + modelId, + ); const reasoningEffort = level ? (level === 'off' ? 'none' : level) : undefined; - // A Responses wire still has two distinct replay contracts. OpenAI's native - // dialect uses encrypted content and therefore reads `openai` options; - // `store: false` asks the SDK to include that replay token. Open Responses - // carries plaintext reasoning items; ModelAdapter passes the Session's - // selected effort through the AI SDK's top-level `reasoning` option. + // 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') { // Connection-aware: a relay model's declared variants count too. const reasons = thinkingVariantsForConnection(connection, modelId).length > 0; - if (reasoningReplay.kind === 'open-responses-plaintext') { - return {}; + if (responsesAdapter === '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: { diff --git a/packages/runtime/src/tool-free-model-call.ts b/packages/runtime/src/tool-free-model-call.ts index a5fc3bc27c..1c51db8cb3 100644 --- a/packages/runtime/src/tool-free-model-call.ts +++ b/packages/runtime/src/tool-free-model-call.ts @@ -1,4 +1,3 @@ -import type { LanguageModelV4CallOptions } from '@ai-sdk/provider'; import type { ModelMessage } from './model-protocol.js'; import { lowerModelTools, normalizeAiSdkUsage, type AiSdkUsageLike } from './model-adapter.js'; import { rawFinishReasonString, type NormalizedUsage } from './model-protocol.js'; @@ -13,7 +12,6 @@ export type ToolFreeModelCallInput = ToolFreeModelCallContent & { /** Optional original Agent system prefix for cache-compatible auxiliary calls. */ readonly system?: string; readonly providerOptions?: unknown; - readonly reasoning?: LanguageModelV4CallOptions['reasoning']; readonly abortSignal?: AbortSignal; readonly maxOutputTokens: number; readonly maxRetries?: number; @@ -32,7 +30,6 @@ export interface ProviderPrefixModelCallInput { readonly tools: ModelToolSet; readonly activeTools: readonly string[]; readonly providerOptions?: unknown; - readonly reasoning?: LanguageModelV4CallOptions['reasoning']; readonly abortSignal?: AbortSignal; readonly maxOutputTokens?: number; /** Anthropic omits Tool schemas when AI SDK receives `none`; omit there and fail closed below. */ @@ -79,7 +76,6 @@ export async function generateProviderPrefixModelCall( ...(input.toolChoicePolicy === 'none' ? { toolChoice: 'none' } : {}), ...(input.abortSignal === undefined ? {} : { abortSignal: input.abortSignal }), ...(input.providerOptions === undefined ? {} : { providerOptions: input.providerOptions }), - ...(input.reasoning === undefined ? {} : { reasoning: input.reasoning }), ...(input.maxOutputTokens === undefined ? {} : { maxOutputTokens: input.maxOutputTokens }), maxRetries: 0, }); @@ -112,7 +108,6 @@ export async function generateToolFreeModelCall( ...(input.prompt === undefined ? { messages: input.messages } : { prompt: input.prompt }), ...(input.abortSignal === undefined ? {} : { abortSignal: input.abortSignal }), ...(input.providerOptions === undefined ? {} : { providerOptions: input.providerOptions }), - ...(input.reasoning === undefined ? {} : { reasoning: input.reasoning }), maxOutputTokens: input.maxOutputTokens, ...(input.maxRetries === undefined ? {} : { maxRetries: input.maxRetries }), }); From 85790fe6a7dac3180b9a6b7539da971fee03ec45 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 09:38:31 +0800 Subject: [PATCH 08/12] fix(runtime): share resolved provider options with auxiliary and diagnostic readers - AiSdkBackend stores one resolvedProviderOptions value (caller-supplied or derived from buildProviderOptions) and reads it for the main call, the memory-extraction snapshot, and the request-shape diagnostics, so every reader describes the request actually sent. - runHostAuxiliaryModelCall lets request-carried frozen provider options win over freshly resolved ones, preserving the source turn's reasoning and provider-specific semantics for memory proposal/extraction. - The Open Responses connection probe normalizes through openResponsesUrl so a base URL that already names the endpoint is not probed as /responses/responses, with a provider-conformance regression pinning the single /responses path. - Drop the providerOptions: undefined assertion from the plaintext replay test; assert part count, type, and text instead of coupling to AI SDK normalization. --- .../src/server/execution-model-authority.ts | 5 +++-- .../src/__tests__/ai-sdk-backend.test.ts | 9 ++++---- .../__tests__/provider-conformance.test.ts | 22 +++++++++++++++++++ packages/runtime/src/ai-sdk-backend.ts | 22 +++++++++---------- packages/runtime/src/test-connection.ts | 4 ++-- 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/packages/runtime-host/src/server/execution-model-authority.ts b/packages/runtime-host/src/server/execution-model-authority.ts index 33457c30e8..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; }) | { @@ -504,13 +505,13 @@ async function runHostAuxiliaryModelCall( ? 'omit' : 'none', abortSignal: input.abortSignal, - providerOptions, + providerOptions: request.providerOptions ?? providerOptions, }) : generateToolFreeModelCall({ model, ...request, abortSignal: input.abortSignal, - providerOptions, + providerOptions: request.providerOptions ?? providerOptions, }); }, input.abortSignal); const oauthFailure = readDeferredOAuthFailure?.(); diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 5255763fd6..460be41e5b 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -12699,10 +12699,11 @@ describe('AiSdkBackend thinking persistence', () => { (message) => message.role === 'assistant' && Array.isArray(message.content), ); assert.ok(assistant && Array.isArray(assistant.content)); - assert.deepEqual( - assistant.content.filter((part) => part.type === 'reasoning'), - [{ type: 'reasoning', text: 'reasoning about the tool', providerOptions: undefined }], - ); + 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'), ); diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index 80dfb61f70..09526e4e29 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -855,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/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index bafc816994..978ae531bb 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -1049,6 +1049,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; @@ -1084,11 +1085,12 @@ export class AiSdkBackend implements AgentBackend { this.now = input.now ?? (() => Date.now()); this.maxSteps = input.maxSteps; this.providerRetrySleep = input.providerRetrySleep ?? sleepForProviderRetry; - const modelCallProviderOptions = buildProviderOptions( - input.connection, - input.modelId, - input.header.thinkingLevel, - ); + // 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, @@ -1099,7 +1101,7 @@ export class AiSdkBackend implements AgentBackend { // 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: input.providerOptions ?? modelCallProviderOptions, + providerOptions: this.resolvedProviderOptions, newId: this.newId, now: this.now, ...(input.openAiResponsesTransportState @@ -1212,9 +1214,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() } : {}), @@ -1995,7 +1995,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, @@ -2059,7 +2059,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, 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}`, From 53e0e273c64ee4c59cdf3b60b4c3118f49f6e759 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 15:53:39 +0800 Subject: [PATCH 09/12] fix(runtime): degrade replay per item and normalize endpoint-form Responses URLs --- .../src/__tests__/ai-sdk-backend.test.ts | 113 ++++++++++++++++++ .../__tests__/responses-wire-contract.test.ts | 74 +++++++++++- packages/runtime/src/ai-sdk-backend.ts | 38 +++++- packages/runtime/src/model-factory.ts | 15 ++- packages/runtime/src/provider-urls.ts | 12 ++ 5 files changed, 246 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 460be41e5b..64be30c86d 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -3095,6 +3095,119 @@ describe('AiSdkBackend model history', () => { 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(); diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index 18677e12d4..1391fd47c6 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -9,7 +9,7 @@ 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 { openResponsesUrl } from '../provider-urls.js'; +import { openAiResponsesBaseUrl, openResponsesUrl } from '../provider-urls.js'; function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmConnection { return { @@ -54,7 +54,77 @@ describe('responses wire contract', () => { assert.equal(openResponsesUrl('https://relay.example/'), 'https://relay.example/responses'); }); - test('every encrypted Responses dialect asks for encrypted reasoning', () => { + 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 the SDK adapter independently from reasoning continuation', () => { + const deepseek = resolveModelRuntime({ providerType: 'deepseek' }, 'deepseek-v4-flash'); + assert.equal(deepseek.responsesAdapter, 'open-responses'); + assert.equal(deepseek.reasoningReplay.kind, 'responses-plaintext-content'); + + const xai = resolveModelRuntime({ providerType: 'xai' }, 'grok-4.5'); + assert.equal(xai.responsesAdapter, 'openai'); + assert.equal(xai.reasoningReplay.kind, 'responses-encrypted-content'); + + const relay = resolveModelRuntime( + { providerType: 'openai-responses-compatible' }, + 'relay-model', + ); + assert.equal(relay.responsesAdapter, 'openai'); + assert.equal(relay.reasoningReplay.kind, 'responses-encrypted-content'); + }); + + test('enables Open Responses only for providers with an explicit continuation contract', () => { + const configured = Object.entries(PROVIDER_REGISTRY).flatMap(([providerType, definition]) => { + const adapter = definition.runtimeAdapter; + return adapter.kind === 'openai-compatible' && adapter.responsesAdapter === 'open-responses' + ? [{ providerType, reasoningReplay: adapter.responsesReasoningReplay }] + : []; + }); + + assert.deepEqual(configured, [ + { providerType: 'deepseek', reasoningReplay: 'plaintext-content' }, + ]); + + const relay = PROVIDER_REGISTRY['openai-responses-compatible'].runtimeAdapter; + assert.equal(relay.kind, 'openai-compatible'); + assert.equal(relay.responsesAdapter, 'openai'); + assert.equal(relay.responsesReasoningReplay, 'encrypted-content'); + }); + + 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 diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 978ae531bb..3bc2f6db41 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -3841,9 +3841,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', @@ -3887,6 +3900,29 @@ export class AiSdkBackend implements AgentBackend { 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 diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index a6396f675e..416a3e59b2 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -27,7 +27,12 @@ import { type OpenAiChatReasoningTransportState, } from './openai-chat-reasoning-transport.js'; import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js'; -import { anthropicV1BaseUrl, googleV1BetaBaseUrl, openResponsesUrl } 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) @@ -155,7 +162,9 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { } return createOpenAI({ apiKey, - baseURL, + // 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); } diff --git a/packages/runtime/src/provider-urls.ts b/packages/runtime/src/provider-urls.ts index 067caaf98d..ab0074a91b 100644 --- a/packages/runtime/src/provider-urls.ts +++ b/packages/runtime/src/provider-urls.ts @@ -44,6 +44,18 @@ export function openResponsesUrl(baseUrl: string): string { 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(/\/+$/, ''); } From 865cd545dc8e176768cef29c4237ee9f15c22420 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 19:51:57 +0800 Subject: [PATCH 10/12] refactor(runtime): simplify Responses contracts --- packages/core/src/llm-connections.ts | 2 + packages/core/src/provider-registry.ts | 25 +++++---- .../src/__tests__/apply-patch-profile.test.ts | 26 ++-------- .../src/__tests__/model-adapter.test.ts | 6 +-- ...enai-responses-plaintext-reasoning.test.ts | 48 +++++++++++++++++ .../src/__tests__/provider-contract-matrix.ts | 10 ++-- .../__tests__/responses-wire-contract.test.ts | 46 +++++++++++------ packages/runtime/src/ai-sdk-backend.ts | 18 +------ packages/runtime/src/apply-patch-profile.ts | 51 ++----------------- packages/runtime/src/codex-v4a-patch.ts | 11 ---- packages/runtime/src/model-adapter.ts | 23 ++++----- packages/runtime/src/model-factory.ts | 17 ++++--- packages/runtime/src/model-runtime.ts | 26 +++------- packages/runtime/src/openai-apply-patch.ts | 1 - packages/runtime/src/tool-runtime.ts | 6 +-- 15 files changed, 139 insertions(+), 177 deletions(-) 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/provider-registry.ts b/packages/core/src/provider-registry.ts index 5dd62e4896..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,11 +40,8 @@ type ProviderRuntimeAdapterDefinition = name: 'provider' | 'connection'; includeUsage?: boolean; requireBaseUrl?: boolean; - supportsOpenAiResponses?: true; - /** SDK provider used for a Responses wire. */ - responsesAdapter?: 'openai' | 'open-responses'; - /** Stateless reasoning continuation representation used by the Responses wire. */ - responsesReasoningReplay?: 'encrypted-content' | 'plaintext-content'; + /** Presence enables Responses and fixes the only supported SDK/replay pairing. */ + responses?: ProviderResponsesContract; replayAssistantReasoningAs?: 'reasoning'; replayAssistantReasoningDetails?: true; }; @@ -840,10 +847,8 @@ const providerRegistry = { runtimeAdapter: { kind: 'openai-compatible', name: 'provider', - supportsOpenAiResponses: true, applyPatchProtocol: 'codex-v4a-freeform', - responsesAdapter: 'open-responses', - responsesReasoningReplay: 'plaintext-content', + responses: { adapter: 'open-responses', reasoningReplay: 'plaintext-content' }, }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', @@ -981,7 +986,7 @@ const providerRegistry = { runtimeAdapter: { kind: 'openai-compatible', name: 'provider', - supportsOpenAiResponses: true, + responses: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, }, modelDiscovery: { kind: 'protocol' }, category: 'overseas', @@ -1004,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/src/__tests__/apply-patch-profile.test.ts b/packages/runtime/src/__tests__/apply-patch-profile.test.ts index 3c9a3e9077..cf6c762343 100644 --- a/packages/runtime/src/__tests__/apply-patch-profile.test.ts +++ b/packages/runtime/src/__tests__/apply-patch-profile.test.ts @@ -49,29 +49,17 @@ describe('ApplyPatch profile routing', () => { ); }); - test('selects Codex V4A freeform for declared DeepSeek V4 Responses models', () => { + test('does not expose the dormant Codex V4A freeform target path', () => { assert.equal( resolveApplyPatchProfile( { wire: 'openai-responses', - responsesAdapter: 'open-responses', applyPatchProtocol: 'codex-v4a-freeform', }, 'deepseek-v4-flash', ), null, ); - assert.deepEqual( - resolveApplyPatchProfile( - { - wire: 'openai-responses', - responsesAdapter: 'openai', - applyPatchProtocol: 'codex-v4a-freeform', - }, - 'deepseek-v4-flash', - ), - { kind: 'codex-v4a-freeform' }, - ); assert.equal( resolveApplyPatchProfile( { wire: 'openai-chat', applyPatchProtocol: 'codex-v4a-freeform' }, @@ -79,16 +67,15 @@ describe('ApplyPatch profile routing', () => { ), null, ); - assert.deepEqual( + assert.equal( resolveApplyPatchProfile( { wire: 'openai-responses', - responsesAdapter: 'openai', applyPatchProtocol: 'codex-v4a-freeform', }, 'deepseek-v4-pro', ), - { kind: 'codex-v4a-freeform' }, + null, ); assert.equal(resolveApplyPatchProfile({ wire: 'openai-responses' }, 'deepseek-v4-flash'), null); }); @@ -138,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 40d6399541..67aa5efe8c 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -68,7 +68,7 @@ describe('ModelAdapter stream and error normalization', () => { providerExecutedTools: true, signedThinking: false, unsignedThinking: true, - responsesThinking: 'none', + responsesReasoning: 'none', }); }); @@ -123,7 +123,7 @@ describe('ModelAdapter stream and error normalization', () => { providerExecutedTools: true, signedThinking: false, unsignedThinking: false, - responsesThinking: 'openai-encrypted', + responsesReasoning: 'encrypted-content', }); }); @@ -147,7 +147,7 @@ describe('ModelAdapter stream and error normalization', () => { providerExecutedTools: false, signedThinking: false, unsignedThinking: false, - responsesThinking: 'open-responses-plaintext', + responsesReasoning: 'plaintext-content', }); }); 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 62e92d15c9..d9c7b66ec1 100644 --- a/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts +++ b/packages/runtime/src/__tests__/openai-responses-plaintext-reasoning.test.ts @@ -110,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 diff --git a/packages/runtime/src/__tests__/provider-contract-matrix.ts b/packages/runtime/src/__tests__/provider-contract-matrix.ts index ebb8cc149a..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' ); @@ -342,7 +342,7 @@ function wireDimensionCell( }; } if ( - (def.runtimeAdapter.kind === 'openai' || def.runtimeAdapter.kind === 'openai-compatible') && + def.runtimeAdapter.kind === 'openai' && def.runtimeAdapter.apiProtocol === 'openai-responses' ) { return { @@ -369,11 +369,7 @@ function reasoningReplayCell( contract: `${adapter.kind} replays reasoning on its provider-specific per-model wire`, }; } - if ( - adapter.kind === 'openai-compatible' && - adapter.supportsOpenAiResponses === true && - adapter.apiProtocol === 'openai-responses' - ) { + if (adapter.kind === 'openai-compatible' && adapter.responses !== undefined) { return { state: 'override', dimension: 'reasoning-replay', diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index 1391fd47c6..5dddbe918f 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -89,39 +89,54 @@ describe('responses wire contract', () => { assert.deepEqual(urls, ['https://relay.example/v1/responses']); }); - test('resolves the SDK adapter independently from reasoning continuation', () => { + test('resolves only supported Responses adapter and replay pairings', () => { const deepseek = resolveModelRuntime({ providerType: 'deepseek' }, 'deepseek-v4-flash'); - assert.equal(deepseek.responsesAdapter, 'open-responses'); - assert.equal(deepseek.reasoningReplay.kind, 'responses-plaintext-content'); + assert.deepEqual(deepseek.reasoningReplay, { + kind: 'responses', + contract: { adapter: 'open-responses', reasoningReplay: 'plaintext-content' }, + }); const xai = resolveModelRuntime({ providerType: 'xai' }, 'grok-4.5'); - assert.equal(xai.responsesAdapter, 'openai'); - assert.equal(xai.reasoningReplay.kind, 'responses-encrypted-content'); + assert.deepEqual(xai.reasoningReplay, { + kind: 'responses', + contract: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, + }); const relay = resolveModelRuntime( { providerType: 'openai-responses-compatible' }, 'relay-model', ); - assert.equal(relay.responsesAdapter, 'openai'); - assert.equal(relay.reasoningReplay.kind, 'responses-encrypted-content'); + assert.deepEqual(relay.reasoningReplay, { + kind: 'responses', + contract: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, + }); }); - test('enables Open Responses only for providers with an explicit continuation contract', () => { + 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.responsesAdapter === 'open-responses' - ? [{ providerType, reasoningReplay: adapter.responsesReasoningReplay }] + return adapter.kind === 'openai-compatible' && adapter.responses + ? [{ providerType, contract: adapter.responses }] : []; }); assert.deepEqual(configured, [ - { providerType: 'deepseek', reasoningReplay: 'plaintext-content' }, + { + 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-compatible'); - assert.equal(relay.responsesAdapter, 'openai'); - assert.equal(relay.responsesReasoningReplay, 'encrypted-content'); + assert.equal(relay.kind, 'openai'); }); test('every encrypted-content Responses contract asks for encrypted reasoning', () => { @@ -147,7 +162,8 @@ describe('responses wire contract', () => { } if ( runtime.wire !== 'openai-responses' || - runtime.reasoningReplay.kind === 'open-responses-plaintext' + (runtime.reasoningReplay.kind === 'responses' && + runtime.reasoningReplay.contract.reasoningReplay === 'plaintext-content') ) { continue; } diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 3bc2f6db41..36bae361ad 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -221,7 +221,6 @@ import { import { modelUsesNativeOpenAiResponses, resolveModelRuntime } from './model-runtime.js'; import { applyPatchReplayFactText, - freeformApplyPatchResultText, normalizeApplyPatchReplayInput, routeApplyPatchTools, type ApplyPatchProfile, @@ -901,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; @@ -3983,7 +3972,7 @@ export class AiSdkBackend implements AgentBackend { } : undefined; } - if (replaySupport.responsesThinking === 'open-responses-plaintext') { + if (replaySupport.responsesReasoning === 'plaintext-content') { if (item.text.length === 0) return undefined; return { part: { @@ -3992,7 +3981,7 @@ export class AiSdkBackend implements AgentBackend { }, }; } - if (replaySupport.responsesThinking === 'openai-encrypted') { + if (replaySupport.responsesReasoning === 'encrypted-content') { const openai = item.providerOptions?.openai; if (openai && typeof openai === 'object' && !Array.isArray(openai)) { const { itemId, reasoningEncryptedContent } = openai as { @@ -4050,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 383d84ced9..5a906bcd18 100644 --- a/packages/runtime/src/apply-patch-profile.ts +++ b/packages/runtime/src/apply-patch-profile.ts @@ -1,19 +1,14 @@ 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, ResponsesAdapter } from './model-runtime.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; - readonly responsesAdapter?: ResponsesAdapter; readonly applyPatchProtocol?: ApplyPatchProtocol; } @@ -23,18 +18,10 @@ export function resolveApplyPatchProfile( modelId: string, ): ApplyPatchProfile | null { if (runtime.wire !== 'openai-responses' || !runtime.applyPatchProtocol) return null; - // The generic Open Responses SDK adapter cannot serialize provider-defined - // custom tools yet. Keep the provider capability declared in the registry, - // but fail closed at the active codec boundary so it returns automatically - // when that adapter gains custom-tool support. - if (runtime.responsesAdapter === 'open-responses') return null; const id = modelId.trim().toLowerCase(); 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; } @@ -47,33 +34,10 @@ 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, @@ -83,11 +47,6 @@ export function normalizeApplyPatchReplayInput( // 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 (profile.kind === 'codex-v4a-freeform') { - if (typeof input === 'string') return input; - const operation = structuredApplyPatchOperation(input); - return operation ? serializeCodexV4aOperation(operation) : null; - } if (typeof input !== 'string') return input; try { const operations = parseCodexV4aPatch(input); 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 2438cf0219..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. @@ -184,19 +181,19 @@ export class ModelAdapter { // 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.responsesAdapter !== 'open-responses', + 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', - responsesThinking: - this.runtime.reasoningReplay.kind === 'openai-responses-encrypted' - ? 'openai-encrypted' - : this.runtime.reasoningReplay.kind === 'open-responses-plaintext' - ? 'open-responses-plaintext' - : 'none', + responsesReasoning: + this.runtime.reasoningReplay.kind === 'responses' + ? this.runtime.reasoningReplay.contract.reasoningReplay + : 'none', }; } @@ -646,7 +643,7 @@ export interface ModelAdapterRuntimeEventReplaySupport { providerExecutedTools: boolean; signedThinking: boolean; unsignedThinking: boolean; - responsesThinking: 'none' | 'openai-encrypted' | 'open-responses-plaintext'; + responsesReasoning: 'none' | 'encrypted-content' | 'plaintext-content'; } /** @@ -920,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 416a3e59b2..f6dc871cfb 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -152,7 +152,10 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { ); } if (wire === 'openai-responses') { - if (reasoningReplay.kind === 'open-responses-plaintext') { + 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, @@ -517,19 +520,19 @@ function buildFamilyWire( level: ThinkingLevel | undefined, thinkingOptions: ThinkingOptions | undefined, ): SharedV4ProviderOptions { - const { adapter, wire, responsesAdapter, reasoningReplay } = resolveModelRuntime( - connection, - modelId, - ); + const { adapter, wire, reasoningReplay } = resolveModelRuntime(connection, modelId); const reasoningEffort = level ? (level === 'off' ? 'none' : level) : undefined; // 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 (responsesAdapter === 'open-responses') { + 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 @@ -543,7 +546,7 @@ function buildFamilyWire( return { openai: { store: false, - ...(reasons || reasoningReplay.kind === 'openai-responses-encrypted' + ...(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 5b5a5a435b..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,10 +20,7 @@ export type ReasoningReplayContract = | { kind: 'none' } | { kind: 'anthropic-signed' } | { kind: 'openai-chat-plaintext'; requestField: 'observed' | 'reasoning' } - | { kind: 'openai-responses-encrypted' } - | { kind: 'open-responses-plaintext' }; - -export type ResponsesAdapter = 'openai' | 'open-responses'; + | { kind: 'responses'; contract: ProviderResponsesContract }; export interface ResolvedModelRuntime { adapter: ProviderRuntimeAdapter; @@ -31,8 +29,6 @@ export interface ResolvedModelRuntime { apiProtocol?: ModelInfo['apiProtocol']; /** Effective wire after account, adapter, and model defaults are resolved. */ wire: ModelRuntimeWire; - /** SDK provider selected for a Responses wire. */ - responsesAdapter?: ResponsesAdapter; /** Durable reasoning replay semantics carried by that wire. */ reasoningReplay: ReasoningReplayContract; /** Effective ApplyPatch contract after provider, model, and request wire are resolved. */ @@ -93,14 +89,10 @@ export function resolveModelRuntime( : resolvedBaseUrl, ...(apiProtocol ? { apiProtocol } : {}), wire, - ...(wire === 'openai-responses' ? { responsesAdapter: responsesAdapterContract(adapter) } : {}), reasoningReplay: reasoningReplayContract(adapter, wire), applyPatchProfile: resolveApplyPatchProfile( { wire, - ...(wire === 'openai-responses' - ? { responsesAdapter: responsesAdapterContract(adapter) } - : {}), applyPatchProtocol: adapter.applyPatchProtocol, }, modelId, @@ -151,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'; @@ -170,10 +162,7 @@ function reasoningReplayContract( case 'anthropic-messages': return { kind: 'anthropic-signed' }; case 'openai-responses': - return adapter.kind === 'openai-compatible' && - adapter.responsesReasoningReplay === 'plaintext-content' - ? { kind: 'open-responses-plaintext' } - : { kind: 'openai-responses-encrypted' }; + return { kind: 'responses', contract: responsesContract(adapter) }; case 'openai-chat': return adapter.kind === 'openai-compatible' ? { @@ -188,10 +177,9 @@ function reasoningReplayContract( } } -function responsesAdapterContract(adapter: ProviderRuntimeAdapter): ResponsesAdapter { - return adapter.kind === 'openai-compatible' && adapter.responsesAdapter === 'open-responses' - ? 'open-responses' - : 'openai'; +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 { 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/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; }; From 21be06c322e541630c989696d9a05f9a5cf15db8 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 20:12:12 +0800 Subject: [PATCH 11/12] test(runtime): pin ApplyPatch downgrade boundary --- .../src/__tests__/ai-sdk-backend.test.ts | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 64be30c86d..76957f010f 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -217,20 +217,18 @@ describe('AiSdkBackend ApplyPatch routing', () => { }); }); - test('downgrades durable DeepSeek freeform apply_patch history to a fact', 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(), @@ -304,6 +302,23 @@ describe('AiSdkBackend ApplyPatch routing', () => { .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', + ); + }); + + 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 () => { From 95b0f5ef901205fd94d2c69cc5d1d6c567203238 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 20:20:26 +0800 Subject: [PATCH 12/12] chore(cli): refresh third-party notices --- packages/cli/THIRD_PARTY_NOTICES.txt | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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