From c2a301b08bfd161522ccc548e8342cd4e92f5464 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 22 Jun 2026 07:15:20 +0200 Subject: [PATCH] feat(ai): allowed custom idle timeout for openai responses streams - Pass `streamIdleTimeoutMs` from model compatibility settings to the streaming logic. - Update catalog definitions for Sakana models to include a 300,000ms idle timeout. - Add a test case to verify that the streaming client honors the model-defined idle timeout. --- packages/ai/src/providers/openai-responses.ts | 3 ++- .../test/openai-first-event-timeout.test.ts | 25 +++++++++++++++++++ packages/ai/test/proxy.test.ts | 7 +++++- packages/catalog/src/compat/openai.ts | 1 + packages/catalog/src/models.json | 9 ++++--- .../src/provider-models/openai-compat.ts | 1 + packages/catalog/src/types.ts | 1 + 7 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 12315959b..fe1e6ad35 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -436,7 +436,8 @@ const streamOpenAIResponsesOnce = ( ? buildOpenAIResponsesChainedParams(params, trailingScaffoldingItems, chainState) : { params }; sentPreviousResponseId = chained.previousResponseId; - const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(); + const idleTimeoutMs = + options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(model.compat.streamIdleTimeoutMs); const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs); const requestTimeoutMs = diff --git a/packages/ai/test/openai-first-event-timeout.test.ts b/packages/ai/test/openai-first-event-timeout.test.ts index dc02252c5..2a13bd494 100644 --- a/packages/ai/test/openai-first-event-timeout.test.ts +++ b/packages/ai/test/openai-first-event-timeout.test.ts @@ -790,4 +790,29 @@ describe("OpenAI-family first-event timeouts", () => { { type: "text", text: "Truncated output", textSignature: '{"v":1,"id":"msg_length_limited"}' }, ]); }); + + it("honors streamIdleTimeoutMs from model.compat for OpenAI responses streams", async () => { + const customResponsesModel: Model<"openai-responses"> = buildModel({ + id: "fugu-test", + name: "Fugu Test", + api: "openai-responses", + provider: "sakana", + baseUrl: "https://api.sakana.ai/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + compat: { streamIdleTimeoutMs: 20 }, + }); + + const fetchMock = () => Promise.resolve(createNoProgressOpenAIResponsesStream(undefined)); + const result = await streamOpenAIResponses(customResponsesModel, baseContext(), { + apiKey: "test-key", + fetch: fetchMock as any, + }).result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("OpenAI responses stream stalled while waiting for the next event"); + }); }); diff --git a/packages/ai/test/proxy.test.ts b/packages/ai/test/proxy.test.ts index 08466ab20..38c5011bb 100644 --- a/packages/ai/test/proxy.test.ts +++ b/packages/ai/test/proxy.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; -import { getProxyForProvider, isLocalOrMetadataHost, shouldBypassProxy, wrapFetchForProxy } from "@oh-my-pi/pi-ai/utils/proxy"; +import { + getProxyForProvider, + isLocalOrMetadataHost, + shouldBypassProxy, + wrapFetchForProxy, +} from "@oh-my-pi/pi-ai/utils/proxy"; const PROXY = "http://127.0.0.1:24560"; diff --git a/packages/catalog/src/compat/openai.ts b/packages/catalog/src/compat/openai.ts index 740c961f8..017b99fdd 100644 --- a/packages/catalog/src/compat/openai.ts +++ b/packages/catalog/src/compat/openai.ts @@ -535,6 +535,7 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol emptyLengthFinishIsContextError: spec.provider === "ollama", usesOpenAIToolCallIdLimit: spec.provider === "openai", promptCacheSessionHeader: spec.provider === "xai-oauth" ? "x-grok-conv-id" : undefined, + streamIdleTimeoutMs: spec.compat?.streamIdleTimeoutMs, }; applyCompatOverrides(compat, spec.compat); if (spec.compat?.reasoningDisableMode === undefined) { diff --git a/packages/catalog/src/models.json b/packages/catalog/src/models.json index eeddcdd1e..2b4026b00 100644 --- a/packages/catalog/src/models.json +++ b/packages/catalog/src/models.json @@ -69272,7 +69272,8 @@ } }, "compat": { - "includeEncryptedReasoning": false + "includeEncryptedReasoning": false, + "streamIdleTimeoutMs": 300000 } }, "fugu-ultra": { @@ -69304,7 +69305,8 @@ } }, "compat": { - "includeEncryptedReasoning": false + "includeEncryptedReasoning": false, + "streamIdleTimeoutMs": 300000 } }, "fugu-ultra-20260615": { @@ -69336,7 +69338,8 @@ } }, "compat": { - "includeEncryptedReasoning": false + "includeEncryptedReasoning": false, + "streamIdleTimeoutMs": 300000 } } }, diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 36af7f0a2..80b2e0559 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -2534,6 +2534,7 @@ const SAKANA_FUGU_THINKING: ThinkingConfig = { }; const SAKANA_RESPONSES_COMPAT: ModelSpec<"openai-responses">["compat"] = { includeEncryptedReasoning: false, + streamIdleTimeoutMs: 300_000, }; function normalizeSakanaBaseUrl(baseUrl: string | undefined): string { diff --git a/packages/catalog/src/types.ts b/packages/catalog/src/types.ts index 3cb195f9d..f06aba641 100644 --- a/packages/catalog/src/types.ts +++ b/packages/catalog/src/types.ts @@ -534,6 +534,7 @@ export interface ResolvedOpenAIResponsesCompat extends ResolvedOpenAISharedCompa supportsImageDetailOriginal: boolean; requiresJuiceZeroHack: boolean; supportsObfuscationOptOut: boolean; + streamIdleTimeoutMs?: number; } /**