From 100d2ef54787571459d3e896a175bb8df910273e Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 12 Aug 2026 05:25:22 +0000 Subject: [PATCH] fix(catalog): bound openai-compatible model discovery with default timeout Built-in OpenAI-compatible provider managers call fetchOpenAICompatibleModels with neither a signal nor a timeoutMs, and the no-timeout branch issued the request with signal: undefined. A stalled /models endpoint left the fetch pending forever, so createAgentSession's awaited resolveModelDiscoveryFallback discovery pass never returned and startup hung. Apply a default 10s deadline (DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS) when the caller supplies neither signal nor timeoutMs, matching the coding-agent remote-discovery budget. Callers passing their own signal keep owning its lifecycle. Fixes #8315 --- packages/catalog/CHANGELOG.md | 4 ++ .../src/discovery/openai-compatible.ts | 25 +++++++-- .../catalog/test/issue-8315-repro.test.ts | 55 +++++++++++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 packages/catalog/test/issue-8315-repro.test.ts diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 564800398..21d6da888 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Bounded OpenAI-compatible model discovery with a default request timeout so a stalled provider `/models` endpoint can no longer hang startup indefinitely in `resolveModelDiscoveryFallback` ([#8315](https://github.com/can1357/oh-my-pi/issues/8315)). + ## [17.2.15] - 2026-08-12 ### Fixed diff --git a/packages/catalog/src/discovery/openai-compatible.ts b/packages/catalog/src/discovery/openai-compatible.ts index a673077a0..92c61f354 100644 --- a/packages/catalog/src/discovery/openai-compatible.ts +++ b/packages/catalog/src/discovery/openai-compatible.ts @@ -4,6 +4,18 @@ import { discoveryFetch } from "../utils"; const MODELS_PATH = "/models"; +/** + * Default hard deadline applied to an OpenAI-compatible `/models` probe when + * the caller supplies neither an `AbortSignal` nor an explicit `timeoutMs`. + * + * Built-in provider model managers (openrouter, xAI, DeepSeek, …) call + * {@link fetchOpenAICompatibleModels} with no timeout, so without this bound a + * stalled endpoint left the request pending forever and blocked startup's + * awaited `resolveModelDiscoveryFallback` discovery pass indefinitely + * (issue #8315). 10s matches the coding-agent's remote-discovery budget. + */ +export const DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS = 10_000; + /** * Uses a cancellable timer rather than the native abort-timeout helper so * successful fast discovery requests do not leave armed timeout signals for @@ -96,7 +108,11 @@ export interface FetchOpenAICompatibleModelsOptions { headers?: Record; /** Optional AbortSignal for request cancellation; caller owns its lifecycle. */ signal?: AbortSignal; - /** Optional cancellable request timeout used when `signal` is omitted. */ + /** + * Optional cancellable request timeout used when `signal` is omitted. + * Defaults to {@link DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS} so a + * stalled endpoint can never hang discovery indefinitely. + */ timeoutMs?: number; /** Optional fetch implementation override for testing/custom runtimes. */ fetch?: FetchImpl; @@ -164,9 +180,10 @@ export async function fetchOpenAICompatibleModels( const payload = options.signal !== undefined ? await fetchPayload(options.signal) - : options.timeoutMs !== undefined - ? await withOpenAICompatibleDiscoveryTimeout(options.timeoutMs, fetchPayload) - : await fetchPayload(); + : await withOpenAICompatibleDiscoveryTimeout( + options.timeoutMs ?? DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, + fetchPayload, + ); if (payload === null) { return null; } diff --git a/packages/catalog/test/issue-8315-repro.test.ts b/packages/catalog/test/issue-8315-repro.test.ts new file mode 100644 index 000000000..75ffcf892 --- /dev/null +++ b/packages/catalog/test/issue-8315-repro.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test"; +import { fetchOpenAICompatibleModels } from "../src/discovery/openai-compatible"; +import type { FetchImpl } from "../src/types"; + +// Issue #8315: `omp` hung at startup in `resolveModelDiscoveryFallback`. +// Built-in OpenAI-compatible provider managers (openrouter, xAI, DeepSeek, …) +// call `fetchOpenAICompatibleModels` with neither a `signal` nor a `timeoutMs`, +// and the no-timeout branch issued the request with `signal: undefined` — so a +// stalled `/models` endpoint left the fetch pending forever and blocked the +// awaited discovery pass indefinitely. +describe("issue #8315: OpenAI-compatible discovery must be bounded by default", () => { + it("arms an abort deadline when the caller supplies neither signal nor timeoutMs", async () => { + let received: AbortSignal | null | undefined = null; + const capturingFetch: FetchImpl = async (_url, init) => { + received = init?.signal; + return new Response(JSON.stringify({ data: [{ id: "m1" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const models = await fetchOpenAICompatibleModels({ + api: "openai-completions", + provider: "custom", + baseUrl: "https://stall.example/v1", + fetch: capturingFetch, + }); + + // Regression guard: previously the transport received `signal: undefined` + // (unbounded). It must now carry a default deadline. + expect(received).toBeInstanceOf(AbortSignal); + expect(models).not.toBeNull(); + expect(models?.map(model => model.id)).toEqual(["m1"]); + }); + + it("resolves to null instead of hanging when the endpoint never responds", async () => { + // Honors the deadline signal by rejecting on abort; never resolves otherwise. + const stallingFetch: FetchImpl = (_url, init) => { + const { promise, reject } = Promise.withResolvers(); + const signal = init?.signal; + signal?.addEventListener("abort", () => reject(signal.reason ?? new Error("aborted"))); + return promise; + }; + + const models = await fetchOpenAICompatibleModels({ + api: "openai-completions", + provider: "custom", + baseUrl: "https://stall.example/v1", + fetch: stallingFetch, + timeoutMs: 50, + }); + + expect(models).toBeNull(); + }); +});