diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index 7d66f6985d..e8f2f547a5 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -97,7 +97,7 @@ describe("XAIHandler", () => { const stream = handler.createMessage("test prompt", []) await stream.next() - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ model: xaiDefaultModelId, instructions: "test prompt", @@ -105,7 +105,6 @@ describe("XAIHandler", () => { store: false, include: ["reasoning.encrypted_content"], }), - undefined, ) }) @@ -222,7 +221,7 @@ describe("XAIHandler", () => { }) await stream.next() - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ tools: [ expect.objectContaining({ @@ -235,7 +234,6 @@ describe("XAIHandler", () => { tool_choice: "auto", parallel_tool_calls: true, }), - undefined, ) }) @@ -260,11 +258,10 @@ describe("XAIHandler", () => { }) await stream.next() - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ parallel_tool_calls: false, }), - undefined, ) }) @@ -274,12 +271,11 @@ describe("XAIHandler", () => { const stream = handler.createMessage("test prompt", []) await stream.next() - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ max_output_tokens: 65_536, temperature: 0, }), - undefined, ) }) @@ -305,6 +301,207 @@ describe("XAIHandler", () => { expect(result).toBe(expectedResponse) }) + it("createMessage should map a forced tool_choice to the Responses API shape", async () => { + const testTools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { type: "object", properties: { arg1: { type: "string" } }, required: ["arg1"] }, + }, + }, + ] + + mockResponsesCreate.mockResolvedValueOnce(asyncStreamFrom([])) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + tool_choice: { type: "function", function: { name: "test_tool" } }, + }) + await stream.next() + + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + tool_choice: { type: "function", name: "test_tool" }, + }), + ) + }) + + it("createMessage should flatten allowed_tools entries to the Responses API shape", async () => { + const testTools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { type: "object", properties: { arg1: { type: "string" } }, required: ["arg1"] }, + }, + }, + ] + + mockResponsesCreate.mockResolvedValueOnce(asyncStreamFrom([])) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + tool_choice: { + type: "allowed_tools", + allowed_tools: { + mode: "required", + tools: [ + { type: "function", function: { name: "test_tool" } }, + { type: "mcp", server_label: "deepwiki" }, + ], + }, + }, + }) + await stream.next() + + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", name: "test_tool" }, + { type: "mcp", server_label: "deepwiki" }, + ], + }, + }), + ) + }) + + it("createMessage should reject an allowed_tools function entry with a malformed name", async () => { + // The Chat Completions allowlist type accepts open records, so a + // function entry can carry a missing or non-string name. It must be + // rejected at the boundary rather than forwarded in the nested Chat + // Completions shape, which the Responses API cannot express. + const testTools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { type: "object", properties: { arg1: { type: "string" } }, required: ["arg1"] }, + }, + }, + ] + const callsBefore = mockResponsesCreate.mock.calls.length + + const malformedEntries = [ + { type: "function", function: { name: null } }, + { type: "function", function: null }, + { type: "function", function: () => 1 }, + { type: "function", function: { name: 123 } }, + ] + for (const entry of malformedEntries) { + // The mapping throws during request construction; the abort bridge must not have + // registered a listener yet, or a malformed tool choice would leak it. + const abortController = new AbortController() + const addListenerSpy = vi.spyOn(abortController.signal, "addEventListener") + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + abortSignal: abortController.signal, + tools: testTools, + tool_choice: { + type: "allowed_tools", + allowed_tools: { + mode: "required", + tools: [entry, { type: "mcp", server_label: "deepwiki" }], + }, + }, + }) + await expect(stream.next()).rejects.toThrow( + 'allowed_tools entry of type "function" is missing a valid function name', + ) + expect(addListenerSpy).not.toHaveBeenCalled() + addListenerSpy.mockRestore() + } + expect(mockResponsesCreate.mock.calls.length).toBe(callsBefore) + }) + + it("createMessage should map a custom tool_choice to the Responses API shape", async () => { + const testTools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { type: "object", properties: { arg1: { type: "string" } }, required: ["arg1"] }, + }, + }, + ] + + mockResponsesCreate.mockResolvedValueOnce(asyncStreamFrom([])) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + tool_choice: { type: "custom", custom: { name: "custom_tool" } }, + }) + await stream.next() + + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + tool_choice: { type: "custom", name: "custom_tool" }, + }), + ) + }) + + it("createMessage should pass through unmappable allowed_tools entries unchanged", async () => { + const testTools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { type: "object", properties: { arg1: { type: "string" } }, required: ["arg1"] }, + }, + }, + ] + + // Entries that are not function references pass through unchanged: a + // non-function type carrying a function ref must not be rewritten. + // Malformed function entries (null ref, function value, non-string name) + // are rejected at the boundary; see the dedicated rejection test. + const entries = [{ type: "mcp", function: { name: "leaky" } }] + + mockResponsesCreate.mockResolvedValueOnce(asyncStreamFrom([])) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + tool_choice: { type: "allowed_tools", allowed_tools: { mode: "auto", tools: entries } }, + }) + await stream.next() + + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + tool_choice: { + type: "allowed_tools", + mode: "auto", + tools: entries, + }, + }), + ) + }) + + it("createMessage should send max_output_tokens and temperature for the default model", async () => { + mockResponsesCreate.mockResolvedValueOnce(asyncStreamFrom([])) + + const stream = handler.createMessage("test prompt", []) + await stream.next() + + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + max_output_tokens: 65_536, + temperature: 0, + }), + ) + }) + it("should handle errors in completePrompt", async () => { const errorMessage = "API error" mockResponsesCreate.mockRejectedValueOnce(new Error(errorMessage)) @@ -346,9 +543,9 @@ describe("XAIHandler", () => { mockResponsesCreate.mockResolvedValueOnce({ output_text: "response" }) await handler.completePrompt("test prompt", { abortSignal: controller.signal }) - expect(mockResponsesCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), { - signal: controller.signal, - }) + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ model: expect.any(String) })) + const requestOptions = mockResponsesCreate.mock.calls[0]?.[1] as { signal?: AbortSignal } | undefined + expect(requestOptions?.signal).toBe(controller.signal) }) it("completePrompt should work without options (backward compatible)", async () => { @@ -356,10 +553,7 @@ describe("XAIHandler", () => { const result = await handler.completePrompt("test prompt") expect(result).toBe("response") - expect(mockResponsesCreate).toHaveBeenCalledWith( - expect.objectContaining({ model: expect.any(String) }), - undefined, - ) + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ model: expect.any(String) })) }) it("completePrompt should send the full Responses API request body", async () => { @@ -367,14 +561,11 @@ describe("XAIHandler", () => { await handler.completePrompt("test prompt") - expect(mockResponsesCreate).toHaveBeenCalledWith( - { - model: xaiDefaultModelId, - input: [{ role: "user", content: [{ type: "input_text", text: "test prompt" }] }], - store: false, - }, - undefined, - ) + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual({ + model: xaiDefaultModelId, + input: [{ role: "user", content: [{ type: "input_text", text: "test prompt" }] }], + store: false, + }) }) it("completePrompt should pass timeout through to client", async () => { @@ -382,10 +573,10 @@ describe("XAIHandler", () => { mockResponsesCreate.mockResolvedValueOnce({ output_text: "response" }) await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 }) - expect(mockResponsesCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), { - signal: controller.signal, - timeout: 5000, - }) + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ model: expect.any(String) })) + const requestOptions = mockResponsesCreate.mock.calls[0]?.[1] as + { signal?: AbortSignal; timeout?: number } | undefined + expect(requestOptions).toMatchObject({ signal: controller.signal, timeout: 5000 }) }) it.each([ @@ -400,9 +591,10 @@ describe("XAIHandler", () => { mockResponsesCreate.mockResolvedValueOnce({ output_text: "response" }) await handler.completePrompt("test prompt", { timeoutMs: input }) - expect(mockResponsesCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), { - timeout: forwarded, - }) + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ model: expect.any(String) }), + ) + expect(mockResponsesCreate.mock.calls[0]?.[1]).toMatchObject({ timeout: forwarded }) }, ) @@ -414,10 +606,11 @@ describe("XAIHandler", () => { mockResponsesCreate.mockClear() mockResponsesCreate.mockResolvedValueOnce({ output_text: "response" }) await handler.completePrompt("test prompt", { timeoutMs }) - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ model: expect.any(String) }), - undefined, ) + // No usable timeout and no signal means the SDK options argument is omitted entirely. + expect(mockResponsesCreate.mock.calls[0]?.[1]).toBeUndefined() }, ) @@ -432,13 +625,12 @@ describe("XAIHandler", () => { const stream = miniModelHandler.createMessage("test prompt", []) await stream.next() - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ reasoning: expect.objectContaining({ effort: "high", }), }), - undefined, ) }) @@ -452,14 +644,13 @@ describe("XAIHandler", () => { const stream = grok45Handler.createMessage("test prompt", []) await stream.next() - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ model: "grok-4.5", reasoning: expect.objectContaining({ effort: "high", }), }), - undefined, ) }) @@ -474,14 +665,13 @@ describe("XAIHandler", () => { const stream = grok45Handler.createMessage("test prompt", []) await stream.next() - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ model: "grok-4.5", reasoning: expect.objectContaining({ effort: "low", }), }), - undefined, ) }) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 87da090346..c1ffdc6d7f 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -143,7 +143,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple for await (const chunk of stream) { switch (chunk.type) { case "message_start": { - const usage = chunk.message!.usage + const usage = chunk.message.usage // `output_tokens_details.thinking_tokens` decomposes the billed // output into internal reasoning tokens; surfaced for per-turn @@ -155,19 +155,19 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple cacheWriteTokens: usage.cache_creation_input_tokens || undefined, cacheReadTokens: usage.cache_read_input_tokens || undefined, ...(typeof usage.output_tokens_details?.thinking_tokens === "number" - ? { reasoningTokens: usage.output_tokens_details!.thinking_tokens } + ? { reasoningTokens: usage.output_tokens_details.thinking_tokens } : {}), } break } case "message_delta": { - const deltaThinkingTokens = chunk.usage!.output_tokens_details?.thinking_tokens + const deltaThinkingTokens = chunk.usage.output_tokens_details?.thinking_tokens yield { type: "usage", inputTokens: 0, - outputTokens: chunk.usage!.output_tokens || 0, + outputTokens: chunk.usage.output_tokens || 0, ...(typeof deltaThinkingTokens === "number" ? { reasoningTokens: deltaThinkingTokens } : {}), @@ -176,17 +176,17 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple break } case "content_block_start": { - switch (chunk.content_block!.type) { + switch (chunk.content_block.type) { case "text": { - if (chunk.index! > 0) { + if (chunk.index > 0) { yield { type: "text", text: "\n" } } - yield { type: "text", text: chunk.content_block!.text } + yield { type: "text", text: chunk.content_block.text } break } case "thinking": { - if (chunk.index! > 0) { + if (chunk.index > 0) { yield { type: "reasoning", text: "\n" } } @@ -198,8 +198,8 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple yield { type: "tool_call_partial", index: chunk.index, - id: chunk.content_block!.id, - name: chunk.content_block!.name, + id: chunk.content_block.id, + name: chunk.content_block.name, arguments: undefined, } break @@ -209,9 +209,9 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple break } case "content_block_delta": { - switch (chunk.delta!.type) { + switch (chunk.delta.type) { case "text_delta": { - yield { type: "text", text: chunk.delta!.text } + yield { type: "text", text: chunk.delta.text } break } case "thinking_delta": { diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index d56a476a8f..4983e0cbaf 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -32,7 +32,7 @@ import { // Pre-sorted list of known Anthropic model IDs (lowercased) by length (descending) for case-insensitive substring matching. const ANTHROPIC_MODEL_IDS_SORTED_LOWER = (Object.keys(anthropicModels) as AnthropicModelId[]) .map((id) => id.toLowerCase()) - .sort((a, b) => b.length - a.length) as string[] + .sort((a, b) => b.length - a.length) // Original-case mapping: lowercase key → original AnthropicModelId for lookup. const ANTHROPIC_MODEL_ID_LOWER_TO_ORIGINAL = Object.fromEntries( @@ -405,7 +405,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa return anthropicModels[anthropicDefaultModelId] } - const originalId = ANTHROPIC_MODEL_ID_LOWER_TO_ORIGINAL[matchedLower] as AnthropicModelId + const originalId = ANTHROPIC_MODEL_ID_LOWER_TO_ORIGINAL[matchedLower] return anthropicModels[originalId] } diff --git a/src/api/providers/nanogpt.ts b/src/api/providers/nanogpt.ts index 58cb29ec6e..4fe1c7b49a 100644 --- a/src/api/providers/nanogpt.ts +++ b/src/api/providers/nanogpt.ts @@ -9,7 +9,6 @@ import { providerIdentifiers, type ModelInfo, type ModelRecord, - type NanoGptRoutingPreference, type ReasoningEffortExtended, } from "@roo-code/types" @@ -115,10 +114,7 @@ export class NanoGptHandler extends RouterProvider implements SingleCompletionHa } private getRequestModelId(canonicalModelId: string): string { - return applyNanoGptRoutingPreference( - canonicalModelId, - this.options.nanoGptRoutingPreference as NanoGptRoutingPreference | undefined, - ) + return applyNanoGptRoutingPreference(canonicalModelId, this.options.nanoGptRoutingPreference) } private createSafeError(operation: string, error: unknown): Error { diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index e77741ad9e..67e177987d 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -627,7 +627,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion if (block.type === "text") { content.push({ type: "input_text", text: block.text }) } else if (block.type === "image") { - const image = block as Anthropic.Messages.ImageBlockParam + const image = block if (image.source.type === "base64") { const imageUrl = `data:${image.source.media_type};base64,${image.source.data}` content.push({ type: "input_image", image_url: imageUrl }) diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index fbbecf5760..9711156347 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -145,8 +145,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio usage.cache_read_input_tokens ?? usage.cache_read_tokens ?? usage.cached_tokens ?? cachedFromDetails ?? 0 // Resolve effective tier: prefer actual tier from response; otherwise requested tier - const effectiveTier = - this.lastServiceTier || (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined + const effectiveTier = this.lastServiceTier || this.options.openAiNativeServiceTier || undefined const effectiveInfo = this.applyServiceTierPricing(model.info, effectiveTier) // Pass total input tokens directly to calculateApiCostOpenAI @@ -486,7 +485,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio if (block.type === "text") { content.push({ type: "input_text", text: block.text }) } else if (block.type === "image") { - const image = block as Anthropic.Messages.ImageBlockParam + const image = block if (image.source.type === "base64") { const imageUrl = `data:${image.source.media_type};base64,${image.source.data}` content.push({ type: "input_image", image_url: imageUrl }) @@ -1551,7 +1550,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Include text.verbosity only when the model explicitly supports it if (model.info.supportsVerbosity === true) { - requestBody.text = { verbosity: (verbosity || "medium") as VerbosityLevel } + requestBody.text = { verbosity: verbosity || "medium" } } // Enable extended prompt cache retention for eligible models diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index f09f3df252..e3c50adb33 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -62,6 +62,61 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler return { id, info, ...params } } + /** + * Map a Chat Completions tool choice to the Responses API shape so TypeScript + * validates the provider payload (the APIs use different object forms for the + * named-tool choices: Chat Completions nests the name under `function`/`custom`, + * Responses API puts it at the top level). String options (auto/required/none) + * are identical in both APIs and pass through unchanged. `allowed_tools` + * entries keep their allowlist mode, but Chat Completions function references + * ({ type: "function", function: { name } }) are flattened to the Responses + * API shape ({ type: "function", name }); other entry types pass through + * unchanged. + */ + private mapToolChoice( + toolChoice: NonNullable, + ): OpenAI.Responses.ResponseCreateParamsStreaming["tool_choice"] { + if (typeof toolChoice === "string") { + return toolChoice + } + switch (toolChoice.type) { + case "function": + return { type: "function", name: toolChoice.function.name } + case "custom": + return { type: "custom", name: toolChoice.custom.name } + case "allowed_tools": + return { + type: "allowed_tools", + mode: toolChoice.allowed_tools.mode, + tools: toolChoice.allowed_tools.tools.map((entry) => { + // Chat Completions allowlist entries nest the function reference + // ({ type: "function", function: { name } }); the Responses API + // expects the name at the top level ({ type: "function", name }). + // The Chat Completions type allows open records, so a function + // entry can arrive malformed (missing or non-string name). + // Forwarding it unchanged would send the nested Chat Completions + // shape to the Responses API, which cannot express that allowlist + // entry, and how xAI handles the malformed payload is unspecified; + // reject it at the boundary instead. + if (entry["type"] === "function") { + const functionRef = entry["function"] + const name = + functionRef != null && typeof functionRef === "object" + ? (functionRef as Record)["name"] + : undefined + if (typeof name !== "string") { + throw new Error( + 'xAI: allowed_tools entry of type "function" is missing a valid function name', + ) + } + return { type: "function", name } + } + return entry + }), + } + } + } + /** * Convert tools from OpenAI Chat Completions format to Responses API format. * Chat Completions: { type: "function", function: { name, description, parameters } } @@ -101,18 +156,11 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler // Convert directly from Anthropic format to Responses API input format const input = convertToResponsesApiInput(messages) const responseTools = this.mapResponseTools(metadata?.tools) - - // Bridge the external abort signal from request metadata into a per-request - // controller so the SDK call is cancelled when the owning request is aborted - // (or when the signal is already aborted). Without an external signal the - // client-level timeout configured in the constructor remains the only - // cancellation mechanism, preserving the existing behavior. - const externalAbortSignal = metadata?.abortSignal - const { signal: abortSignal, dispose: removeExternalAbortListener } = - bridgeExternalAbortSignal(externalAbortSignal) + const toolChoice = metadata?.tool_choice + const parallelToolCalls = metadata?.parallelToolCalls // Build request options - const requestBody: Record = { + const requestBody: OpenAI.Responses.ResponseCreateParamsStreaming = { model: model.id, instructions: systemPrompt, input: input, @@ -121,19 +169,16 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler include: ["reasoning.encrypted_content"], } - if (model.maxTokens) { - requestBody.max_output_tokens = model.maxTokens - } - - if (model.temperature !== undefined) { - requestBody.temperature = model.temperature - } + // Model params are always resolved by getModel(); send them unconditionally. + requestBody.max_output_tokens = model.maxTokens + requestBody.temperature = model.temperature if (responseTools) { requestBody.tools = responseTools - // Cast tool_choice since metadata uses Chat Completions types but Responses API has its own type - requestBody.tool_choice = (metadata?.tool_choice ?? "auto") as any - requestBody.parallel_tool_calls = metadata?.parallelToolCalls ?? true + // Metadata carries a Chat Completions tool choice; the Responses API + // uses its own shape, so map it explicitly instead of casting. + requestBody.tool_choice = this.mapToolChoice(toolChoice === undefined ? "auto" : toolChoice) + requestBody.parallel_tool_calls = parallelToolCalls ?? true } // Pass reasoning effort for models that support it (e.g., grok-4.5, grok-3-mini). @@ -143,11 +188,19 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler requestBody.reasoning = { effort: model.reasoning.reasoning_effort } } + // Bridge the external abort signal only after every throw-prone part of the request + // construction (notably mapToolChoice, which rejects malformed allowed_tools entries) + // has run: the bridge's cleanup covers the request below, not construction failures, + // so registering earlier would leak a listener on metadata.abortSignal. + const externalAbortSignal = metadata?.abortSignal + const { signal: abortSignal, dispose: removeExternalAbortListener } = + bridgeExternalAbortSignal(externalAbortSignal) + try { let stream: AsyncIterable try { stream = (await this.client.responses.create( - { ...requestBody, stream: true } as any, + requestBody, abortSignal ? { signal: abortSignal } : undefined, )) as unknown as AsyncIterable } catch (error) { diff --git a/src/api/transform/model-params.ts b/src/api/transform/model-params.ts index 08c066f21a..249b18d1d9 100644 --- a/src/api/transform/model-params.ts +++ b/src/api/transform/model-params.ts @@ -137,7 +137,7 @@ export function getModelParams({ // Here we simply propagate the resolved effort into the params, while // still treating "disable" as an omission. if (effort && effort !== "disable") { - reasoningEffort = effort as ReasoningEffortExtended + reasoningEffort = effort } } diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index d0f0df172b..50a4a6d015 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -465,7 +465,7 @@ export function convertToOpenAiMessages( ] as OpenAI.Chat.ChatCompletionToolMessageParam if (lastToolMessage?.role === "tool") { const additionalText = filteredNonToolMessages - .map((part) => sanitizeSurrogates((part as Anthropic.TextBlockParam).text)) + .map((part) => sanitizeSurrogates(part.text)) .join("\n") lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` } diff --git a/src/api/transform/reasoning.ts b/src/api/transform/reasoning.ts index 3d3c68c03a..dc1d634156 100644 --- a/src/api/transform/reasoning.ts +++ b/src/api/transform/reasoning.ts @@ -63,7 +63,7 @@ export const getOpenRouterReasoning = ({ ? { max_tokens: reasoningBudget } : shouldUseReasoningEffort({ model, settings }) ? reasoningEffort && reasoningEffort !== "disable" - ? { effort: reasoningEffort as ReasoningEffortExtended } + ? { effort: reasoningEffort } : undefined : undefined @@ -185,8 +185,7 @@ export const getGeminiReasoning = ({ // We intentionally ignore enableReasoningEffort here so that explicitly chosen // efforts in the UI (e.g. "High" for gemini-3-pro-preview) always translate // into a thinkingConfig, regardless of legacy boolean flags. - const selectedEffort = (settings.reasoningEffort ?? model.reasoningEffort) as - ReasoningEffortExtended | "disable" | undefined + const selectedEffort = settings.reasoningEffort ?? model.reasoningEffort // Respect "off" / unset semantics from the effort selector itself. if (!selectedEffort || selectedEffort === "disable") { diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 8a9e1fa4df..32263840bf 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -446,7 +446,7 @@ }, "api/providers/xai.ts": { "@typescript-eslint/no-explicit-any": { - "count": 7 + "count": 4 } }, "api/transform/__tests__/ai-sdk.spec.ts": { diff --git a/src/eslint.config.mjs b/src/eslint.config.mjs index d5c4d73c1f..f82e677029 100644 --- a/src/eslint.config.mjs +++ b/src/eslint.config.mjs @@ -94,6 +94,21 @@ export default [ "@typescript-eslint/no-floating-promises": "error", }, }, + { + // Ratchet: flag type assertions (`as X`, `!`) that do not change the type. + // They hide type errors once an SDK type narrows. Tests are not covered yet. + files: ["api/**/*.ts"], + ignores: ["api/**/__tests__/**"], + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/no-unnecessary-type-assertion": "error", + }, + }, { // Tests inside __tests__/ directories must not traverse 3+ levels above // the test file. That escapes the src/ package root and makes Turbo