Skip to content

Commit f965db9

Browse files
authored
feat: add headerTimeout cfg option, default it on only for openai w/ default of 10s (anomalyco#29484)
1 parent 519d344 commit f965db9

8 files changed

Lines changed: 257 additions & 8 deletions

File tree

packages/opencode/src/config/provider.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,19 @@ export const Info = Schema.Struct({
9292
timeout: Schema.optional(
9393
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
9494
description:
95-
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
95+
"Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.",
96+
}),
97+
).annotate({
98+
description: "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.",
99+
}),
100+
headerTimeout: Schema.optional(
101+
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
102+
description:
103+
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
96104
}),
97105
).annotate({
98106
description:
99-
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
107+
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
100108
}),
101109
chunkTimeout: Schema.optional(PositiveInt).annotate({
102110
description:

packages/opencode/src/provider/error.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ import { STATUS_CODES } from "http"
33
import { iife } from "@/util/iife"
44
import type { ProviderID } from "./schema"
55

6+
export class HeaderTimeoutError extends Error {
7+
public override readonly name = "ProviderHeaderTimeoutError"
8+
9+
constructor(public readonly ms: number) {
10+
super(`Provider response headers timed out after ${ms}ms`)
11+
}
12+
}
13+
614
// Adapted from overflow detection patterns in:
715
// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts
816
const OVERFLOW_PATTERNS = [

packages/opencode/src/provider/provider.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ import * as ProviderTransform from "./transform"
2828
import { ModelID, ProviderID } from "./schema"
2929
import { ModelStatus } from "./model-status"
3030
import { RuntimeFlags } from "@/effect/runtime-flags"
31+
import { ProviderError } from "./error"
3132

3233
const log = Log.create({ service: "provider" })
33-
34+
const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000
3435
function shouldUseCopilotResponsesApi(modelID: string): boolean {
3536
const match = /^gpt-(\d+)/.exec(modelID)
3637
if (!match) return false
@@ -85,6 +86,15 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
8586
})
8687
}
8788

89+
function timeoutController(ms: number) {
90+
const ctl = new AbortController()
91+
const id = setTimeout(() => ctl.abort(new ProviderError.HeaderTimeoutError(ms)), ms)
92+
return {
93+
signal: ctl.signal,
94+
clear: () => clearTimeout(id),
95+
}
96+
}
97+
8898
function googleVertexAnthropicBaseURL(project: string | undefined, location: string | undefined) {
8999
if (!project) return
90100
if (location !== "eu" && location !== "us") return
@@ -194,7 +204,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
194204
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
195205
return sdk.responses(modelID)
196206
},
197-
options: {},
207+
options: { headerTimeout: OPENAI_HEADER_TIMEOUT_DEFAULT },
198208
}),
199209
xai: () =>
200210
Effect.succeed({
@@ -1601,16 +1611,21 @@ export const layer = Layer.effect(
16011611

16021612
const customFetch = options["fetch"]
16031613
const chunkTimeout = options["chunkTimeout"]
1614+
const headerTimeout = options["headerTimeout"]
16041615
delete options["chunkTimeout"]
1616+
delete options["headerTimeout"]
16051617

16061618
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
16071619
const fetchFn = customFetch ?? fetch
16081620
const opts = init ?? {}
16091621
const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
1622+
const headerTimeoutMs = headerTimeout === false ? undefined : headerTimeout
1623+
const headerTimeoutCtl = typeof headerTimeoutMs === "number" ? timeoutController(headerTimeoutMs) : undefined
16101624
const signals: AbortSignal[] = []
16111625

16121626
if (opts.signal) signals.push(opts.signal)
16131627
if (chunkAbortCtl) signals.push(chunkAbortCtl.signal)
1628+
if (headerTimeoutCtl) signals.push(headerTimeoutCtl.signal)
16141629
if (options["timeout"] !== undefined && options["timeout"] !== null && options["timeout"] !== false)
16151630
signals.push(AbortSignal.timeout(options["timeout"]))
16161631

@@ -1639,7 +1654,7 @@ export const layer = Layer.effect(
16391654
...opts,
16401655
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
16411656
timeout: false,
1642-
})
1657+
}).finally(() => headerTimeoutCtl?.clear())
16431658

16441659
if (!chunkAbortCtl) return res
16451660
return wrapSSE(res, chunkTimeout, chunkAbortCtl)

packages/opencode/src/session/message-v2.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,6 +1143,18 @@ export function fromError(
11431143
},
11441144
{ cause: e },
11451145
).toObject()
1146+
case e instanceof ProviderError.HeaderTimeoutError:
1147+
return new APIError(
1148+
{
1149+
message: e.message,
1150+
isRetryable: true,
1151+
metadata: {
1152+
code: e.name,
1153+
timeoutMs: String(e.ms),
1154+
},
1155+
},
1156+
{ cause: e },
1157+
).toObject()
11461158
case APICallError.isInstance(e):
11471159
const parsed = ProviderError.parseAPICallError({
11481160
providerID: ctx.providerID,
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { afterEach, expect } from "bun:test"
2+
import { createServer, type Server } from "node:http"
3+
import { streamText } from "ai"
4+
import { Effect, Layer } from "effect"
5+
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
6+
import { disposeAllInstances, provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
7+
import { testEffect } from "../lib/effect"
8+
import { reply, TestLLMServer } from "../lib/llm-server"
9+
import { testProviderConfig } from "../lib/test-provider"
10+
import { Env } from "@/env"
11+
import { Plugin } from "@/plugin"
12+
import { Provider } from "@/provider/provider"
13+
import { ModelID, ProviderID } from "@/provider/schema"
14+
15+
afterEach(async () => {
16+
await disposeAllInstances()
17+
})
18+
19+
const it = testEffect(
20+
Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, TestLLMServer.layer, CrossSpawnSpawner.defaultLayer),
21+
)
22+
23+
it.live("headerTimeout does not abort delayed SSE body after headers arrive", () =>
24+
provideTmpdirServer(
25+
({ llm }) =>
26+
Effect.gen(function* () {
27+
yield* llm.push(reply().wait(Bun.sleep(250)).text("late").stop())
28+
29+
const provider = yield* Provider.Service
30+
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
31+
const result = streamText({
32+
model: yield* provider.getLanguage(model),
33+
messages: [{ role: "user", content: "hello" }],
34+
})
35+
36+
expect(yield* Effect.promise(() => result.text)).toBe("late")
37+
}),
38+
{
39+
config: (url) => {
40+
const config = testProviderConfig(url)
41+
return {
42+
...config,
43+
provider: {
44+
test: {
45+
...config.provider.test,
46+
options: { ...config.provider.test.options, headerTimeout: 50 },
47+
},
48+
},
49+
}
50+
},
51+
},
52+
),
53+
)
54+
55+
it.live("headerTimeout aborts when response headers do not arrive", () =>
56+
Effect.gen(function* () {
57+
const server = yield* Effect.acquireRelease(
58+
Effect.promise(() => delayedHeaderServer(250)),
59+
(server) => Effect.sync(() => server.server.close()),
60+
)
61+
62+
yield* provideTmpdirInstance(
63+
() =>
64+
Effect.gen(function* () {
65+
const provider = yield* Provider.Service
66+
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
67+
const result = streamText({
68+
model: yield* provider.getLanguage(model),
69+
onError() {},
70+
messages: [{ role: "user", content: "hello" }],
71+
})
72+
73+
const errors = yield* Effect.promise(async () => {
74+
const errors: string[] = []
75+
for await (const part of result.fullStream) {
76+
if (part.type === "error") errors.push(String(part.error))
77+
}
78+
return errors
79+
})
80+
expect(errors.join("\n")).toContain("response headers timed out")
81+
}),
82+
{ config: providerConfig(server.url, { headerTimeout: 50 }) },
83+
)
84+
}),
85+
)
86+
87+
it.live("headerTimeout is opt-in for non-OpenAI providers", () =>
88+
Effect.gen(function* () {
89+
const server = yield* Effect.acquireRelease(
90+
Effect.promise(() => delayedHeaderServer(100)),
91+
(server) => Effect.sync(() => server.server.close()),
92+
)
93+
94+
yield* provideTmpdirInstance(
95+
() =>
96+
Effect.gen(function* () {
97+
const provider = yield* Provider.Service
98+
const model = yield* provider.getModel(ProviderID.make("test"), ModelID.make("test-model"))
99+
const result = streamText({
100+
model: yield* provider.getLanguage(model),
101+
messages: [{ role: "user", content: "hello" }],
102+
})
103+
104+
expect(yield* Effect.promise(() => result.text)).toBe("ok")
105+
}),
106+
{ config: providerConfig(server.url) },
107+
)
108+
}),
109+
)
110+
111+
it.live("OpenAI Codex headerTimeout default can be disabled by config", () =>
112+
Effect.gen(function* () {
113+
yield* withAuthContent(
114+
Effect.gen(function* () {
115+
yield* provideTmpdirInstance(
116+
() =>
117+
Effect.gen(function* () {
118+
const provider = yield* Provider.Service
119+
const openai = yield* provider.getProvider(ProviderID.openai)
120+
expect(openai.options.headerTimeout).toBe(false)
121+
}),
122+
{ config: { provider: { openai: { options: { headerTimeout: false } } } } },
123+
)
124+
}),
125+
)
126+
}),
127+
)
128+
129+
it.live("OpenAI API auth gets default headerTimeout", () =>
130+
Effect.gen(function* () {
131+
yield* withAuthContent(
132+
Effect.gen(function* () {
133+
yield* provideTmpdirInstance(() =>
134+
Effect.gen(function* () {
135+
const provider = yield* Provider.Service
136+
const openai = yield* provider.getProvider(ProviderID.openai)
137+
expect(openai.options.headerTimeout).toBe(10_000)
138+
}),
139+
)
140+
}),
141+
{ openai: { type: "api", key: "sk-test" } },
142+
)
143+
}),
144+
)
145+
146+
function providerConfig(url: string, options: Record<string, unknown> = {}) {
147+
const config = testProviderConfig(url)
148+
return {
149+
...config,
150+
provider: {
151+
test: {
152+
...config.provider.test,
153+
options: { ...config.provider.test.options, ...options },
154+
},
155+
},
156+
}
157+
}
158+
159+
async function delayedHeaderServer(delay: number): Promise<{ server: Server; url: string }> {
160+
const server = createServer((_, res) => {
161+
setTimeout(() => {
162+
res.writeHead(200, { "content-type": "text/event-stream" })
163+
res.end('data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n')
164+
}, delay)
165+
})
166+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
167+
const address = server.address()
168+
if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port")
169+
return { server, url: `http://127.0.0.1:${address.port}` }
170+
}
171+
172+
function withAuthContent<A, E, R>(self: Effect.Effect<A, E, R>, value: Record<string, unknown> = defaultAuthContent()) {
173+
return Effect.acquireUseRelease(
174+
Effect.sync(() => {
175+
const previous = process.env.OPENCODE_AUTH_CONTENT
176+
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify(value)
177+
return previous
178+
}),
179+
() => self,
180+
(previous) =>
181+
Effect.sync(() => {
182+
if (previous === undefined) delete process.env.OPENCODE_AUTH_CONTENT
183+
else process.env.OPENCODE_AUTH_CONTENT = previous
184+
}),
185+
)
186+
}
187+
188+
function defaultAuthContent() {
189+
return {
190+
openai: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 },
191+
}
192+
}

packages/opencode/test/provider/provider.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,9 +282,10 @@ it.instance(
282282
expect(providers[ProviderID.anthropic]).toBeDefined()
283283
// Config options should be merged
284284
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
285+
expect(providers[ProviderID.anthropic].options.headerTimeout).toBe(10000)
285286
expect(providers[ProviderID.anthropic].options.chunkTimeout).toBe(15000)
286287
}),
287-
{ config: { provider: { anthropic: { options: { timeout: 60000, chunkTimeout: 15000 } } } } },
288+
{ config: { provider: { anthropic: { options: { timeout: 60000, headerTimeout: 10000, chunkTimeout: 15000 } } } } },
288289
)
289290

290291
it.instance("getModel returns model for valid provider/model", () =>

packages/opencode/test/session/retry.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
77
import { SessionRetry } from "../../src/session/retry"
88
import { MessageV2 } from "../../src/session/message-v2"
99
import { ProviderID } from "../../src/provider/schema"
10+
import { ProviderError } from "../../src/provider/error"
1011
import { SessionID } from "../../src/session/schema"
1112
import { SessionStatus } from "../../src/session/status"
1213
import { provideTmpdirInstance } from "../fixture/fixture"
@@ -163,6 +164,14 @@ describe("session.retry.retryable", () => {
163164
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
164165
})
165166

167+
test("retries transport timeout errors", () => {
168+
const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID })
169+
expect(MessageV2.APIError.isInstance(request)).toBe(true)
170+
expect(SessionRetry.retryable(request, retryProvider)).toEqual({
171+
message: "Provider response headers timed out after 10000ms",
172+
})
173+
})
174+
166175
test("does not retry context overflow errors", () => {
167176
const error = new MessageV2.ContextOverflowError({
168177
message: "Input exceeds context window of this model",

packages/sdk/js/src/v2/gen/types.gen.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,11 +1049,15 @@ export type ProviderConfig = {
10491049
enterpriseUrl?: string
10501050
setCacheKey?: boolean
10511051
/**
1052-
* Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.
1052+
* Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.
10531053
*/
10541054
timeout?: number | false
1055+
/**
1056+
* Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.
1057+
*/
1058+
headerTimeout?: number | false
10551059
chunkTimeout?: number
1056-
[key: string]: unknown | string | boolean | number | false | number | undefined
1060+
[key: string]: unknown | string | boolean | number | false | number | false | number | undefined
10571061
}
10581062
models?: {
10591063
[key: string]: {

0 commit comments

Comments
 (0)