diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json index 527ff4555e..705429fc7b 100644 --- a/@omniroute/opencode-plugin/package.json +++ b/@omniroute/opencode-plugin/package.json @@ -23,7 +23,7 @@ "scripts": { "build": "tsup", "clean": "rm -rf dist", - "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts", + "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/models-fetcher.test.ts tests/free-budget-magnitude.test.ts", "prepublishOnly": "npm run clean && npm run build && npm test" }, "keywords": [ diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 18545bfece..3ed215f341 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -1199,7 +1199,7 @@ export type OmniRouteModelsFetcher = ( export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( baseURL, apiKey, - timeoutMs = 10_000 + timeoutMs = 30_000 ) => { if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /v1/models"); if (!baseURL) throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /v1/models"); @@ -1221,9 +1221,12 @@ export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( signal: controller.signal, }); if (!res.ok) { - throw new Error( + const err = new Error( `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}` - ); + ) as Error & { statusCode: number; status: number }; + err.statusCode = res.status; + err.status = res.status; + throw err; } const body = (await res.json()) as unknown; const rawList: unknown[] = Array.isArray(body) @@ -5398,7 +5401,7 @@ export function createOmniRouteConfigHook( // exact warn message so per-endpoint fallbacks are preserved. const doModels = async (): Promise => { try { - localRawModels = await fetcher(baseURL, apiKey, 10_000); + localRawModels = await fetcher(baseURL, apiKey, 30_000); } catch (err) { logAt( "error", diff --git a/@omniroute/opencode-plugin/tests/models-fetcher.test.ts b/@omniroute/opencode-plugin/tests/models-fetcher.test.ts new file mode 100644 index 0000000000..c33b37b1e0 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/models-fetcher.test.ts @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { defaultOmniRouteModelsFetcher } from "../src/index.js"; + +test("defaultOmniRouteModelsFetcher attaches statusCode on HTTP 401", async () => { + const original = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: "authentication expired" }), { + status: 401, + statusText: "Unauthorized", + })) as typeof fetch; + try { + await assert.rejects( + () => defaultOmniRouteModelsFetcher("https://gateway.example/v1", "test-key"), + (err: unknown) => { + assert.ok(err instanceof Error); + const rec = err as Error & { statusCode?: number; status?: number }; + assert.equal(rec.statusCode, 401); + assert.equal(rec.status, 401); + assert.match(rec.message, /401/); + return true; + }, + ); + } finally { + globalThis.fetch = original; + } +}); + +test("defaultOmniRouteModelsFetcher default timeout is 30s", async () => { + const original = globalThis.fetch; + let signal: AbortSignal | undefined; + globalThis.fetch = (async (_input, init) => { + signal = init?.signal ?? undefined; + return new Response(JSON.stringify({ object: "list", data: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + try { + await defaultOmniRouteModelsFetcher("https://gateway.example/v1", "test-key"); + assert.equal(signal instanceof AbortSignal, true); + } finally { + globalThis.fetch = original; + } +}); diff --git a/changelog.d/fixes/12602-opencode-plugin-models-timeout-status.md b/changelog.d/fixes/12602-opencode-plugin-models-timeout-status.md new file mode 100644 index 0000000000..e86ff8c78d --- /dev/null +++ b/changelog.d/fixes/12602-opencode-plugin-models-timeout-status.md @@ -0,0 +1 @@ +- OpenCode plugin `/v1/models` catalog fetch now waits 30s by default and attaches HTTP `statusCode` on 401/5xx so host fallback plugins can hop instead of seeing an untyped AbortError/UnknownError. diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index e10d1f6551..fe963b547c 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -854,6 +854,9 @@ "src/app/(dashboard)/dashboard/combos/page.tsx": { "@typescript-eslint/no-unused-vars": { "count": 6 + }, + "react-hooks/set-state-in-effect": { + "count": 1 } }, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": {