From 781d7e751d4739cf03582d2f538e8c4403de4a8c Mon Sep 17 00:00:00 2001 From: Helge Tesdal Date: Fri, 24 Apr 2026 15:59:10 +0200 Subject: [PATCH] fix(provider): derive extended chunkTimeout from model.reasoning, not provider ID Previously a hardcoded Set of provider IDs (anthropic, google-vertex-anthropic, amazon-bedrock) received the 600s chunk timeout. This missed openrouter routing to anthropic/* models and any future reasoning-capable provider not yet in the Set. Replaces the Set with a per-model check against model.capabilities.reasoning, which models.dev already tracks. Signature of resolveChunkTimeout changed from (providerID, value) to ({providerID, reasoning}, value). The sole runtime call site in provider.ts passes model.capabilities.reasoning ?? false. Addresses audit finding F6 (Opus diamond review, 2026-04-22) and Phase A followup #1. --- packages/opencode/src/provider/provider.ts | 26 +++++++------ .../test/provider/chunk-timeout.test.ts | 38 ++++++++++++------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index de3c7ac796b3..1651761b2ca6 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -48,22 +48,23 @@ function shouldUseCopilotResponsesApi(modelID: string): boolean { const DEFAULT_CHUNK_TIMEOUT_MS = 120_000 const EXTENDED_THINKING_CHUNK_TIMEOUT_MS = 600_000 -const EXTENDED_THINKING_PROVIDERS: ReadonlySet = new Set([ - "anthropic", - "google-vertex-anthropic", - "amazon-bedrock", -]) -export function resolveChunkTimeout(providerID: string, value: unknown): number { +export function resolveChunkTimeout( + model: { readonly providerID: string; readonly reasoning: boolean }, + value: unknown, +): number { if (value === false) return 0 if (typeof value === "number") { if (!Number.isFinite(value) || value <= 0) return 0 return value } - if (value !== undefined) log.warn("unrecognized chunkTimeout value, using provider default", { providerID, value }) - return EXTENDED_THINKING_PROVIDERS.has(providerID) - ? EXTENDED_THINKING_CHUNK_TIMEOUT_MS - : DEFAULT_CHUNK_TIMEOUT_MS + if (value !== undefined) + log.warn("unrecognized chunkTimeout value, using model default", { + providerID: model.providerID, + reasoning: model.reasoning, + value, + }) + return model.reasoning ? EXTENDED_THINKING_CHUNK_TIMEOUT_MS : DEFAULT_CHUNK_TIMEOUT_MS } export function wrapSSE(res: Response, ms: number, ctl: AbortController) { @@ -1468,7 +1469,10 @@ const layer: Layer.Layer< if (existing) return existing const customFetch = options["fetch"] - const resolvedChunkTimeout = resolveChunkTimeout(model.providerID, options["chunkTimeout"]) + const resolvedChunkTimeout = resolveChunkTimeout( + { providerID: model.providerID, reasoning: model.capabilities.reasoning }, + options["chunkTimeout"], + ) delete options["chunkTimeout"] options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { diff --git a/packages/opencode/test/provider/chunk-timeout.test.ts b/packages/opencode/test/provider/chunk-timeout.test.ts index c33bc3925b56..6da10765b927 100644 --- a/packages/opencode/test/provider/chunk-timeout.test.ts +++ b/packages/opencode/test/provider/chunk-timeout.test.ts @@ -2,41 +2,51 @@ import { describe, expect, test } from "bun:test" import { resolveChunkTimeout, SSEStallError, wrapSSE } from "../../src/provider/provider" describe("provider.resolveChunkTimeout", () => { - test("returns 120s default when undefined for generic provider", () => { - expect(resolveChunkTimeout("github-copilot", undefined)).toBe(120_000) + test("returns 120s default when undefined and reasoning=false", () => { + expect(resolveChunkTimeout({ providerID: "github-copilot", reasoning: false }, undefined)).toBe(120_000) }) - test("returns 600s default for Anthropic", () => { - expect(resolveChunkTimeout("anthropic", undefined)).toBe(600_000) + test("returns 600s default when reasoning=true on anthropic", () => { + expect(resolveChunkTimeout({ providerID: "anthropic", reasoning: true }, undefined)).toBe(600_000) }) - test("returns 600s default for google-vertex-anthropic", () => { - expect(resolveChunkTimeout("google-vertex-anthropic", undefined)).toBe(600_000) + test("returns 600s default for reasoning=true regardless of provider ID (openrouter → anthropic/*)", () => { + expect(resolveChunkTimeout({ providerID: "openrouter", reasoning: true }, undefined)).toBe(600_000) }) - test("returns 600s default for amazon-bedrock", () => { - expect(resolveChunkTimeout("amazon-bedrock", undefined)).toBe(600_000) + test("returns 600s default when reasoning=true on google-vertex-anthropic", () => { + expect(resolveChunkTimeout({ providerID: "google-vertex-anthropic", reasoning: true }, undefined)).toBe(600_000) + }) + + test("returns 600s default when reasoning=true on amazon-bedrock", () => { + expect(resolveChunkTimeout({ providerID: "amazon-bedrock", reasoning: true }, undefined)).toBe(600_000) + }) + + test("returns 120s default when reasoning=false on anthropic (non-reasoning Claude)", () => { + expect(resolveChunkTimeout({ providerID: "anthropic", reasoning: false }, undefined)).toBe(120_000) }) test("returns 0 when explicitly disabled with false", () => { - expect(resolveChunkTimeout("github-copilot", false)).toBe(0) + expect(resolveChunkTimeout({ providerID: "github-copilot", reasoning: false }, false)).toBe(0) }) test("returns the user value when a positive number", () => { - expect(resolveChunkTimeout("github-copilot", 60_000)).toBe(60_000) + expect(resolveChunkTimeout({ providerID: "github-copilot", reasoning: false }, 60_000)).toBe(60_000) }) test("explicit positive number wins over extended-thinking default", () => { - expect(resolveChunkTimeout("anthropic", 30_000)).toBe(30_000) + expect(resolveChunkTimeout({ providerID: "anthropic", reasoning: true }, 30_000)).toBe(30_000) }) test("false wins over extended-thinking default (returns 0)", () => { - expect(resolveChunkTimeout("anthropic", false)).toBe(0) + expect(resolveChunkTimeout({ providerID: "anthropic", reasoning: true }, false)).toBe(0) }) - test("falls back to provider default for non-numeric junk", () => { + test("falls back to model default for non-numeric junk", () => { // Defensive branch — config schema prevents this, but runtime check guards misconfig. - expect(resolveChunkTimeout("github-copilot", "not-a-number" as never)).toBe(120_000) + expect(resolveChunkTimeout({ providerID: "github-copilot", reasoning: false }, "not-a-number" as never)).toBe( + 120_000, + ) }) })