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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<TApi extends Api> {
|
||||
headers?: Record<string, string>;
|
||||
/** 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<TApi extends Api>(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<Response>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user