fix(models): report failed bundled catalog refreshes (#139649)

Related: #136257. Builds on merged #139357.

Make all bundled shared-helper catalog callers report actual acquisition failures and authoritative empty results. Preserve shipped external advisory defaults through the same transport/cache implementation.

The existing publication owner retains compatible learned inventory or degraded provider-owned starters. Static preparation consumes registered hooks with their authoritative plugin identity, without activating unknown runtimes or widening successful empty membership. No operator option, persistence schema, new cache, or auth re-observation is introduced.

Proof: retained built baseline failure, packaged CLI/Gateway fault/recovery/empty checks, public SDK/provider API compatibility, fresh/upgrade starter checks, and the decisive seven-clause downstream composite on exact shared head 5e0ecef411cc7bcabbcab2672227910a05209ffd. Composite 296bfa19ec7663044ce50b8ebb3652555900d531 adds only the unchanged separate DeepInfra/NVIDIA producer patch; that patch is not part of this PR. Controlled clock advancement and fixture-rejected background inference are disclosed. No real vendor inference or UI claim.

Production net +199; tests/support +313; docs +34; ratchet metadata -1; generated/lockfile delta zero. Growth preserves shipped API and shared failure/empty/ownership contracts rather than adding parallel provider policy.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Ayaan Zaidi 2026-09-06 10:03:57 +05:30 • committed by GitHub
parent b3bdf3e11d
commit d141dc65ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
87 changed files with 1510 additions and 965 deletions

View file

@ -3310,7 +3310,6 @@ src/plugin-sdk/provider-auth-login-flow-runtime.ts 6
src/plugin-sdk/provider-auth-result.ts 4
src/plugin-sdk/provider-auth-runtime.ts 1
src/plugin-sdk/provider-auth.ts 1
src/plugin-sdk/provider-catalog-live-runtime.ts 2
src/plugin-sdk/provider-catalog-shared.ts 1
src/plugin-sdk/provider-enable-config.ts 2
src/plugin-sdk/provider-entry.ts 3

View file

@ -93,7 +93,8 @@ including a successful empty account list. Metadata alone cannot refill that lis
explicitly configured models and independent native runtime catalogs remain.
When a provider reports failed discovery, the Gateway retains its last compatible
inventory and reports the failed outcome while healthy providers update. Without
compatible inventory, the provider's own advisory fallback rows remain visible.
compatible inventory, the Gateway publishes the provider's prepared starter rows
with the failed outcome, even when strict discovery returns no rows.
They cannot widen a retained successful list, including an empty list. Changes to
provider, plugin, auth, environment, or workspace identity invalidate incompatible inventory.

View file

@ -273,6 +273,33 @@ catalog, API-key auth, and dynamic model resolution.
| Admission | Optional. Set `acceptUnknownModel: ({ id, record }) => boolean` when your request shaping is model-version specific, so discovery cannot publish a model you cannot yet build a valid request for. It is called only for IDs your static catalog does not already publish; known IDs bypass it and keep their published metadata. Return `false` to drop the row. Providers that omit it keep the previous behavior unchanged. Prefer comparing the vendor's advertised capabilities against your own contract checks over a hand-maintained model list, and fail closed when the row carries no capability data. |
| Failure | Live discovery is advisory. Auth, network, timeout, pagination, parsing, empty-catalog, and filtering failures return the provider-owned static seed instead of removing the provider. |
Bundled providers set `discoveryMode: "strict"` in their catalog options.
This code option keeps successful empty results empty and reports failed
acquisition through `ProviderCatalogResult.outcomes`, rather than returning
seed models as a successful refresh. HTTP 401/403 produces a catalog-scoped
`auth-rejected` outcome; other acquisition failures produce `unavailable`.
Neither a static catalog nor skipped discovery produces a live outcome.
Each outcome carries the profile selected for the actual request, when one
supplied its credential. Family providers report each sibling independently.
Public metadata requests declare `authentication: "none"` in discovery
options. The prepared request then has no credential or profile identity;
its cache key is independent of the configured inference credential.
The returned provider configuration still retains its inference credential.
External calls that omit `discoveryMode` retain the advisory contract above.
The public Chutes, Hugging Face, KiloCode, and Vercel AI Gateway discovery
functions and builders also retain that default. Their bundled catalog hooks
pass `{ discoveryMode: "strict" }` explicitly; Hugging Face discovery accepts
this options object after its existing timeout argument. The Chutes public
default retains its anonymous retry after HTTP 401; strict calls never retry
without the selected credential.
The strict and advisory paths share the same guarded transport and cache.
Custom live builders can use `runLiveProviderCatalog` at their catalog hook
to convert acquisition errors into outcomes. Keep metadata-feed fallback
separate from account discovery; do not retry a rejected account request
anonymously or substitute seed rows inside a strict builder.
For a non-Bearer or nonstandard list endpoint, pass options instead of
`true`:

View file

@ -66,11 +66,10 @@ the Chutes catalog.
When Chutes auth is available, OpenClaw queries `GET /v1/models` with that
credential and uses the discovered models, cached for 5 minutes per
credential. On an expired/unauthorized key (HTTP 401), OpenClaw retries once
without credentials. If discovery still returns no rows, fails, or returns any
other non-2xx status, it falls back to the bundled static catalog (both API-key
and OAuth discovery use this same path). If discovery fails at startup, the
static catalog is used automatically.
credential. A rejected credential produces a catalog authentication failure;
OpenClaw does not retry anonymously. Other request failures produce an
unavailable catalog outcome, not a successful static list. A successful empty
response stays empty. API-key and OAuth discovery use this same path.
Token prices come from the native [Chutes model catalog](https://llm.chutes.ai/v1/models).
Its numeric prompt, completion, and cached-input rates are already in USD per

View file

@ -94,7 +94,7 @@ When your token is valid, OpenClaw also discovers any other model from **GET** `
The response is OpenAI-style: `{ "object": "list", "data": [ { "id": "Qwen/Qwen3-8B", "owned_by": "Qwen", ... }, ... ] }`.
With a configured key (onboarding, `HUGGINGFACE_HUB_TOKEN`, or `HF_TOKEN`), the **Default Hugging Face model** dropdown during interactive setup is populated from this endpoint. Gateway startup repeats the same call to refresh the catalog. Discovered models are merged with the built-in catalog above (used for metadata like context window and cost when an id matches). If the request fails, returns no data, or no key is set, OpenClaw falls back to the built-in catalog only.
With a configured key (onboarding, `HUGGINGFACE_HUB_TOKEN`, or `HF_TOKEN`), the **Default Hugging Face model** dropdown during interactive setup is populated from this endpoint. Gateway startup repeats the same call to refresh the catalog. Matching built-in models supply metadata such as context window and cost. Failed discovery produces a catalog failure outcome; a successful empty response stays empty. Without a key, the static catalog remains available without starting discovery.
Disable discovery without removing the provider:

View file

@ -53,8 +53,8 @@ The default model is `kilocode/kilo-auto/balanced`, Kilo Gateway's balanced smar
OpenClaw does not publish a task-to-upstream-model mapping for it; routing behind
`kilo-auto/balanced` is owned by Kilo Gateway.
At startup OpenClaw queries `GET https://api.kilo.ai/api/gateway/models` and merges discovered models
ahead of a static fallback catalog. The static fallback contains only
At startup OpenClaw queries `GET https://api.kilo.ai/api/gateway/models` and combines a nonempty public list
with the static routing entry. The static catalog contains only
`kilocode/kilo-auto/balanced` (`Auto Balanced`, `input: ["text", "image"]`, `reasoning: true`,
`contextWindow: 1000000`, `maxTokens: 65536`).
@ -101,7 +101,7 @@ Any model on the gateway is addressable as `kilocode/<upstream-id>` (for example
</Accordion>
<Accordion title="Troubleshooting">
- If model discovery fails at startup, OpenClaw falls back to the static catalog containing `kilocode/kilo-auto/balanced`.
- If model discovery fails, OpenClaw reports an unavailable catalog refresh. It does not replace the failed request with static rows or turn an empty response into `kilocode/kilo-auto/balanced`.
- Confirm your API key is valid and that your Kilo account has the desired models enabled.
- When Gateway runs as a daemon, ensure `KILOCODE_API_KEY` is available to that process (for example in `~/.openclaw/.env` or via `env.shellEnv`).

View file

@ -119,6 +119,11 @@ deprecated models are excluded from active discovery and its offline fallback.
Deprecated explicit refs remain resolvable for existing configurations but are
not shown as current recommendations.
Account-list failures produce a failed catalog outcome, not a successful seed
list. A successful empty or fully filtered account response stays empty.
The separate public metadata feed can still use trusted offline metadata when
it is unavailable; that does not replace or retry the account-list request.
Price estimates also refresh through the [hosted model catalog](/concepts/models#hosted-catalog-updates),
using the same public OpenCode pricing feed as live discovery. Hosted updates
activate after the next Gateway restart; the bundled snapshot remains available

View file

@ -82,7 +82,9 @@ Model refs follow the pattern `openrouter/<provider>/<model>`. For the full list
available providers and models, see [/concepts/model-providers](/concepts/model-providers).
</Note>
Bundled fallback models, used when live catalog discovery is unavailable:
Bundled starter models enrich a nonempty public catalog. A failed live request
reports a discovery failure rather than substituting these rows; a successful
empty response stays empty:
| Model ref | Notes |
| --------------------------------- | ---------------------------- |

View file

@ -154,6 +154,7 @@ function restoreUnpublishedAnthropicModels(result: ProviderCatalogResult): Provi
// Discovered rows arrive id-sorted; keep the appended tail sorted too so the
// catalog stays byte-stable for prompt caching.
return {
...result,
provider: {
...result.provider,
models: [...discovered, ...unpublished.toSorted((a, b) => a.id.localeCompare(b.id))],
@ -836,6 +837,7 @@ export function buildAnthropicProvider(): ProviderPlugin {
run: async (ctx) =>
restoreUnpublishedAnthropicModels(
await buildOpenAICompatibleProviderCatalog({
discoveryMode: "strict",
ctx,
providerId,
buildProvider: buildAnthropicCatalogProvider,

View file

@ -3,8 +3,10 @@ import {
registerSingleProviderPlugin,
resolveProviderPluginChoice,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { clearLiveCatalogCacheForTests } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { resolveProviderAuthEnvVarCandidates } from "openclaw/plugin-sdk/provider-env-vars";
import { describe, expect, it } from "vitest";
import * as ssrfRuntime from "openclaw/plugin-sdk/ssrf-runtime";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { runSingleProviderCatalog } from "../test-support/provider-model-test-helpers.js";
import arceePlugin from "./index.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
@ -89,6 +91,25 @@ describe("arcee provider plugin", () => {
});
it("builds the direct Arcee AI model catalog", async () => {
clearLiveCatalogCacheForTests();
const release = vi.fn(async () => undefined);
const fetchGuard = vi
.spyOn(ssrfRuntime, "fetchWithSsrFGuard")
.mockImplementation(async ({ url }) => ({
response: Response.json({
data: [
{ id: "trinity-mini", object: "model" },
{ id: "trinity-large-preview", object: "model" },
{ id: "trinity-large-thinking", object: "model" },
],
}),
finalUrl: url,
release,
}));
onTestFinished(() => {
fetchGuard.mockRestore();
clearLiveCatalogCacheForTests();
});
const provider = await registerSingleProviderPlugin(arceePlugin);
const catalogProvider = await runSingleProviderCatalog(provider, {
resolveProviderApiKey: (id?: string) =>
@ -98,10 +119,15 @@ describe("arcee provider plugin", () => {
expect(catalogProvider.api).toBe("openai-completions");
expect(catalogProvider.baseUrl).toBe("https://api.arcee.ai/api/v1");
expect(catalogProvider.models?.map((model) => model.id)).toEqual([
"trinity-mini",
"trinity-large-preview",
"trinity-large-thinking",
"trinity-mini",
]);
expect(fetchGuard).toHaveBeenCalledOnce();
const request = fetchGuard.mock.calls[0]?.[0];
expect(request?.url).toBe("https://api.arcee.ai/api/v1/models");
expect(new Headers(request?.init?.headers).get("authorization")).toBe("Bearer test-key");
expect(release).toHaveBeenCalledOnce();
const thinkingCompat = catalogProvider.models?.find(
(model) => model.id === "trinity-large-thinking",
)?.compat;

View file

@ -2,7 +2,7 @@
* Arcee AI provider plugin entry. It supports direct Arcee auth and OpenRouter
* routing while normalizing OpenRouter model ids and base URLs.
*/
import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { buildOpenAICompatibleLiveProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import {
readConfiguredProviderCatalogEntries,
type ProviderCatalogContext,
@ -32,14 +32,14 @@ const ARCEE_WIZARD_GROUP = {
async function resolveArceeCatalog(ctx: ProviderCatalogContext) {
const directAuth = ctx.resolveProviderApiKey(PROVIDER_ID);
if (directAuth.apiKey) {
return {
provider: await buildOpenAICompatibleLiveModelProviderConfig({
providerId: PROVIDER_ID,
providerConfig: buildArceeProvider(),
apiKey: directAuth.apiKey,
discoveryApiKey: directAuth.discoveryApiKey,
}),
};
return await buildOpenAICompatibleLiveProviderCatalog({
discoveryMode: "strict",
providerId: PROVIDER_ID,
providerConfig: buildArceeProvider(),
apiKey: directAuth.apiKey,
discoveryApiKey: directAuth.discoveryApiKey,
profileId: directAuth.profileId,
});
}
const openRouterKey = ctx.resolveProviderApiKey("openrouter").apiKey;

View file

@ -1,5 +1,5 @@
/** Baseten provider plugin entrypoint. */
import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { buildOpenAICompatibleLiveProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
@ -31,26 +31,26 @@ export default defineSingleProviderPluginEntry({
catalog: {
order: "simple",
run: async (ctx: ProviderCatalogContext) => {
const { apiKey, discoveryApiKey } = ctx.resolveProviderAuth(PROVIDER_ID);
const { apiKey, discoveryApiKey, profileId } = ctx.resolveProviderAuth(PROVIDER_ID);
if (!apiKey) {
return null;
}
if (!discoveryApiKey) {
return { provider: { ...buildStaticBasetenProvider(), apiKey } };
}
return {
provider: await buildOpenAICompatibleLiveModelProviderConfig({
providerId: PROVIDER_ID,
providerConfig: buildStaticBasetenProvider(),
apiKey,
discoveryApiKey,
modelDiscovery: {
timeoutMs: 10_000,
ttlMs: 5 * 60 * 1000,
projectRows: projectBasetenLiveModels,
},
}),
};
return await buildOpenAICompatibleLiveProviderCatalog({
discoveryMode: "strict",
providerId: PROVIDER_ID,
providerConfig: buildStaticBasetenProvider(),
apiKey,
discoveryApiKey,
profileId,
modelDiscovery: {
timeoutMs: 10_000,
ttlMs: 5 * 60 * 1000,
projectRows: projectBasetenLiveModels,
},
});
},
staticRun: async () => ({ provider: buildStaticBasetenProvider() }),
},

View file

@ -26,6 +26,7 @@ export default defineSingleProviderPluginEntry({
ensureModelAllowlistEntry({ cfg, modelRef: BYTEPLUS_DEFAULT_MODEL_REF }),
},
...buildOpenAICompatibleProviderFamilyCatalog({
discoveryMode: "strict",
credentialProviderId: PROVIDER_ID,
entries: BYTEPLUS_PROVIDER_CATALOG.entries,
staticCatalog: BYTEPLUS_PROVIDER_CATALOG.staticCatalog,

View file

@ -26,6 +26,7 @@ export default defineSingleProviderPluginEntry({
noteTitle: "Cerebras",
},
catalog: {
discoveryMode: "strict",
allowExplicitBaseUrl: true,
liveModelDiscovery: CEREBRAS_MODEL_DISCOVERY,
},

View file

@ -39,6 +39,7 @@ function createCatalogContext(overrides: Partial<CatalogContext> = {}): CatalogC
resolveProviderApiKey: () => ({
apiKey: "fixture-cerebras-key",
discoveryApiKey: "fixture-discovery-key",
profileId: "cerebras:fixture-profile",
}),
resolveProviderAuth: () => ({
apiKey: "fixture-cerebras-key",
@ -298,12 +299,13 @@ describe("Cerebras native catalog", () => {
expect(glm).not.toHaveProperty("replacedBy");
});
it("falls back offline after discovery failure and retries on the next request", async () => {
it("reports unavailable discovery and recovers on the next request", async () => {
mockCatalogResponse({ error: "unavailable" }, { status: 503 });
const fallback = await runCerebrasCatalog();
expect(fallback.models.map((model) => model.id)).toEqual(
manifest.modelCatalog.providers.cerebras.models.map((model) => model.id),
);
const provider = await registerSingleProviderPlugin(plugin);
await expect(provider.catalog?.run(createCatalogContext())).resolves.toEqual({
providers: {},
outcomes: [{ provider: "cerebras", status: "unavailable" }],
});
mockCatalogResponse({ data: [NATIVE_TEXT_MODEL] });
const recovered = await runCerebrasCatalog();

View file

@ -71,7 +71,7 @@ export const CEREBRAS_MODEL_DISCOVERY: OpenAICompatibleModelDiscoveryOptions = {
url: "https://api.cerebras.ai/public/v1/models",
requireBaseUrl: manifest.modelCatalog.providers.cerebras.baseUrl,
},
buildRequestHeaders: () => ({ Accept: "application/json" }),
authentication: "none",
projectRows: projectCerebrasModels,
};

View file

@ -9,6 +9,7 @@ import {
buildOauthProviderAuthResult,
} from "openclaw/plugin-sdk/provider-auth";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { runLiveProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import {
normalizeOptionalString,
readStringValue,
@ -173,18 +174,22 @@ export default definePluginEntry({
catalog: {
order: "profile",
run: async (ctx) => {
const { apiKey, discoveryApiKey } = ctx.resolveProviderAuth(PROVIDER_ID, {
const { apiKey, discoveryApiKey, profileId } = ctx.resolveProviderAuth(PROVIDER_ID, {
oauthMarker: resolveOAuthApiKeyMarker(PROVIDER_ID),
});
if (!apiKey) {
return null;
}
return {
provider: {
...(await buildChutesProvider(discoveryApiKey)),
apiKey,
},
};
return await runLiveProviderCatalog({
providerId: PROVIDER_ID,
profileId: discoveryApiKey ? profileId : undefined,
run: async () => ({
provider: {
...(await buildChutesProvider(discoveryApiKey, { discoveryMode: "strict" })),
apiKey,
},
}),
});
},
},
staticCatalog: {

View file

@ -1,7 +1,7 @@
// Chutes tests cover models plugin behavior.
import { clearLiveCatalogCacheForTests } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CHUTES_DEFAULT_MODEL_ID } from "./api.js";
import { buildChutesProvider, CHUTES_DEFAULT_MODEL_ID } from "./api.js";
import { CHUTES_MODEL_CATALOG, discoverChutesModels } from "./models.js";
import {
applyChutesConfig,
@ -349,14 +349,18 @@ describe("chutes-models", () => {
});
});
it("does not cache fallback static catalog for non-OK responses", async () => {
it("propagates uncached discovery failures", async () => {
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 503 }));
await withLiveChutesDiscovery(mockFetch, async () => {
const first = await discoverChutesModels("chutes-fallback-token");
const second = await discoverChutesModels("chutes-fallback-token");
expect(first.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id));
expect(second.map((m) => m.id)).toEqual(CHUTES_MODEL_CATALOG.map((m) => m.id));
await expect(
discoverChutesModels("chutes-fallback-token", { discoveryMode: "strict" }),
).rejects.toMatchObject({
status: 503,
});
await expect(buildChutesProvider("chutes-fallback-token")).resolves.toMatchObject({
models: CHUTES_MODEL_CATALOG,
});
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
@ -395,7 +399,7 @@ describe("chutes-models", () => {
});
});
it("does not cache 401 fallback under the failed token key", async () => {
it("does not replace rejected account discovery with an anonymous catalog", async () => {
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
if (readAuthorizationHeader(init) === "Bearer failed-token") {
return Promise.resolve(new Response("", { status: 401 }));
@ -407,15 +411,16 @@ describe("chutes-models", () => {
);
});
await withLiveChutesDiscovery(mockFetch, async () => {
const first = await discoverChutesModels("failed-token");
const second = await discoverChutesModels("failed-token");
expect(requireChutesModel(first, 0).id).toBe("public/model");
expect(requireChutesModel(second, 0).id).toBe("public/model");
await expect(
discoverChutesModels("failed-token", { discoveryMode: "strict" }),
).rejects.toMatchObject({ status: 401 });
await expect(buildChutesProvider("failed-token")).resolves.toMatchObject({
models: [{ id: "public/model" }],
});
expect(mockFetch.mock.calls.map(([, init]) => readAuthorizationHeader(init))).toEqual([
"Bearer failed-token",
"",
"Bearer failed-token",
"",
]);
expect(mockFetch).toHaveBeenCalledTimes(3);
});

View file

@ -99,9 +99,12 @@ function projectChutesModels(rows: readonly unknown[]): ModelDefinitionConfig[]
return models;
}
/** Discovers Chutes models dynamically, falling back to the bundled static catalog. */
export async function discoverChutesModels(accessToken?: string): Promise<ModelDefinitionConfig[]> {
export async function discoverChutesModels(
accessToken?: string,
options: { discoveryMode?: "strict" } = {},
): Promise<ModelDefinitionConfig[]> {
const provider = await buildLiveModelProviderConfig({
...options,
providerId: "chutes",
endpoint: `${CHUTES_BASE_URL}/models`,
providerConfig: { baseUrl: CHUTES_BASE_URL, api: "openai-completions" },

View file

@ -15,13 +15,15 @@ export function buildStaticChutesProvider(): ModelProviderConfig {
/**
* Build the Chutes provider with dynamic model discovery.
* Falls back to the static catalog on failure.
* Accepts an optional access token (API key or OAuth access token) for authenticated discovery.
*/
export async function buildChutesProvider(accessToken?: string): Promise<ModelProviderConfig> {
export async function buildChutesProvider(
accessToken?: string,
options: { discoveryMode?: "strict" } = {},
): Promise<ModelProviderConfig> {
return {
baseUrl: CHUTES_BASE_URL,
api: "openai-completions",
models: await discoverChutesModels(accessToken),
models: await discoverChutesModels(accessToken, options),
};
}

View file

@ -15,6 +15,7 @@ export default defineSingleProviderPluginEntry({
docsPath: "/providers/cohere",
manifestAuth: { applyConfig: applyCohereConfig },
catalog: {
discoveryMode: "strict",
liveModelDiscovery: COHERE_LIVE_MODEL_DISCOVERY,
},
wrapStreamFn: wrapCohereProviderStream,

View file

@ -22,6 +22,7 @@ export default defineSingleProviderPluginEntry({
docsPath: "/providers/deepseek",
manifestAuth: { applyConfig: applyDeepSeekConfig },
catalog: {
discoveryMode: "strict",
buildProvider: buildDeepSeekProvider,
buildStaticProvider: buildDeepSeekProvider,
liveModelDiscovery: true,

View file

@ -89,6 +89,7 @@ export default defineSingleProviderPluginEntry({
].join("\n"),
},
catalog: {
discoveryMode: "strict",
allowExplicitBaseUrl: true,
liveModelDiscovery: {
endpointPath: "models?capabilities=chat",

View file

@ -82,6 +82,7 @@ export default defineSingleProviderPluginEntry({
docsPath: "/providers/fireworks",
manifestAuth: { applyConfig: applyFireworksConfig },
catalog: {
discoveryMode: "strict",
allowExplicitBaseUrl: true,
liveModelDiscovery: true,
},

View file

@ -21,6 +21,7 @@ export default defineSingleProviderPluginEntry({
noteMessage: "Manage API keys at https://www.gmicloud.ai/",
},
catalog: {
discoveryMode: "strict",
allowExplicitBaseUrl: true,
liveModelDiscovery: true,
},

View file

@ -140,7 +140,7 @@ export default defineSingleProviderPluginEntry({
provider: {
label: "Groq",
docsPath: "/providers/groq",
catalog: { liveModelDiscovery: true },
catalog: { liveModelDiscovery: true, discoveryMode: "strict" },
wrapStreamFn: (ctx) =>
wrapGroqOversizedRequestRecovery(
ctx.streamFn,

View file

@ -1,3 +1,4 @@
import { runLiveProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { applyHuggingfaceConfig, HUGGINGFACE_DEFAULT_MODEL_REF } from "./onboard.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
@ -35,16 +36,19 @@ export default defineSingleProviderPluginEntry({
if (discoveryEnabled === false) {
return null;
}
const { apiKey, discoveryApiKey } = ctx.resolveProviderApiKey(PROVIDER_ID);
const { apiKey, discoveryApiKey, profileId } = ctx.resolveProviderApiKey(PROVIDER_ID);
if (!apiKey) {
return null;
}
return {
const run = async () => ({
provider: {
...(await buildHuggingfaceProvider(discoveryApiKey)),
...(await buildHuggingfaceProvider(discoveryApiKey, { discoveryMode: "strict" })),
apiKey,
},
};
});
return discoveryApiKey
? await runLiveProviderCatalog({ providerId: PROVIDER_ID, profileId, run })
: await run();
},
// Startup and unauthenticated catalog reads must not depend on live discovery.
staticRun: async () => ({ provider: await buildHuggingfaceProvider() }),

View file

@ -2,6 +2,7 @@
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildHuggingfaceProvider,
discoverHuggingfaceModels,
HUGGINGFACE_MODEL_CATALOG,
isHuggingfacePolicyLocked,
@ -18,6 +19,19 @@ afterEach(() => {
});
describe("huggingface models", () => {
it.each([503, 200])(
"preserves the public advisory builder for HTTP %s with no rows",
async (status) => {
vi.stubGlobal(
"fetch",
vi.fn(async () => Response.json({ data: [] }, { status })),
);
await expect(buildHuggingfaceProvider("synthetic-key")).resolves.toMatchObject({
models: HUGGINGFACE_MODEL_CATALOG,
});
},
);
it("does not advertise the retired Llama 3.3 Turbo route", () => {
expect(HUGGINGFACE_MODEL_CATALOG.map((model) => model.id)).not.toContain(
"meta-llama/Llama-3.3-70B-Instruct-Turbo",
@ -213,8 +227,9 @@ describe("huggingface models", () => {
),
);
await discoverHuggingfaceModels("hf_test_token");
await expect(
discoverHuggingfaceModels("hf_test_token", undefined, { discoveryMode: "strict" }),
).rejects.toMatchObject({ status: 500 });
expect(timeoutSpy).toHaveBeenCalledWith(30_000);
});
@ -228,7 +243,11 @@ describe("huggingface models", () => {
),
);
await discoverHuggingfaceModels("hf_test_token", 25_000);
await expect(
discoverHuggingfaceModels("hf_test_token", 25_000, { discoveryMode: "strict" }),
).rejects.toMatchObject({
status: 500,
});
expect(timeoutSpy).toHaveBeenCalledWith(25_000);
});
@ -243,12 +262,16 @@ describe("huggingface models", () => {
),
);
await discoverHuggingfaceModels("hf_test_token", Number.MAX_SAFE_INTEGER);
await expect(
discoverHuggingfaceModels("hf_test_token", Number.MAX_SAFE_INTEGER, {
discoveryMode: "strict",
}),
).rejects.toMatchObject({ status: 500 });
expect(timeoutSpy).toHaveBeenCalledWith(MAX_TIMER_TIMEOUT_MS);
});
it("cancels the response body before falling back after an HTTP error", async () => {
it("cancels the response body before propagating an HTTP error", async () => {
stubAbortSignalTimeout();
const response = new Response("unavailable", { status: 503 });
const cancel = vi.spyOn(response.body!, "cancel");
@ -257,15 +280,13 @@ describe("huggingface models", () => {
vi.fn(async () => response),
);
const models = await discoverHuggingfaceModels("hf_test_token");
expect(models.map((model) => model.id)).toEqual(
HUGGINGFACE_MODEL_CATALOG.map((model) => model.id),
);
await expect(
discoverHuggingfaceModels("hf_test_token", undefined, { discoveryMode: "strict" }),
).rejects.toMatchObject({ status: 503 });
expect(cancel).toHaveBeenCalledTimes(1);
});
it("falls back to the static catalog when the discovery response exceeds the byte cap", async () => {
it("propagates discovery response overflow after releasing the reader", async () => {
const chunk = new Uint8Array(1024 * 1024);
const pull = vi.fn((controller: ReadableStreamDefaultController<Uint8Array>) => {
controller.enqueue(chunk);
@ -280,9 +301,9 @@ describe("huggingface models", () => {
vi.fn(async () => response),
);
const models = await discoverHuggingfaceModels("hf_test_token");
expect(models.map((m) => m.id)).toEqual(HUGGINGFACE_MODEL_CATALOG.map((m) => m.id));
await expect(
discoverHuggingfaceModels("hf_test_token", undefined, { discoveryMode: "strict" }),
).rejects.toThrow("Live model catalog response exceeded");
expect(cancel).toHaveBeenCalledTimes(1);
expect(response.body?.locked).toBe(false);
expect(response.bodyUsed).toBe(true);

View file

@ -134,6 +134,7 @@ function projectHuggingfaceModels(rows: readonly unknown[]): ModelDefinitionConf
export async function discoverHuggingfaceModels(
apiKey: string,
timeoutMs = HUGGINGFACE_DISCOVERY_TIMEOUT_MS,
options: { discoveryMode?: "strict" } = {},
): Promise<ModelDefinitionConfig[]> {
const trimmedKey = apiKey?.trim();
if (!trimmedKey) {
@ -142,6 +143,7 @@ export async function discoverHuggingfaceModels(
const requestTimeoutMs = resolveTimerTimeoutMs(timeoutMs, HUGGINGFACE_DISCOVERY_TIMEOUT_MS);
const provider = await buildLiveModelProviderConfig({
...options,
providerId: "huggingface",
endpoint: `${HUGGINGFACE_BASE_URL}/models`,
providerConfig: { baseUrl: HUGGINGFACE_BASE_URL, api: "openai-completions" },

View file

@ -1,10 +1,13 @@
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types";
import { discoverHuggingfaceModels, HUGGINGFACE_BASE_URL } from "./models.js";
export async function buildHuggingfaceProvider(discoveryApiKey = ""): Promise<ModelProviderConfig> {
export async function buildHuggingfaceProvider(
discoveryApiKey = "",
options: { discoveryMode?: "strict" } = {},
): Promise<ModelProviderConfig> {
return {
baseUrl: HUGGINGFACE_BASE_URL,
api: "openai-completions",
models: await discoverHuggingfaceModels(discoveryApiKey),
models: await discoverHuggingfaceModels(discoveryApiKey, undefined, options),
};
}

View file

@ -22,7 +22,8 @@ export default defineSingleProviderPluginEntry({
applyConfig: applyKilocodeConfig,
},
catalog: {
buildProvider: buildKilocodeProviderWithDiscovery,
discoveryMode: "strict",
buildProvider: () => buildKilocodeProviderWithDiscovery({ discoveryMode: "strict" }),
buildStaticProvider: buildKilocodeProvider,
},
augmentModelCatalog: ({ config }) =>

View file

@ -14,8 +14,10 @@ export function buildKilocodeProvider(): ModelProviderConfig {
});
}
export async function buildKilocodeProviderWithDiscovery(): Promise<ModelProviderConfig> {
const models = await discoverKilocodeModels();
export async function buildKilocodeProviderWithDiscovery(
options: { discoveryMode?: "strict" } = {},
): Promise<ModelProviderConfig> {
const models = await discoverKilocodeModels(options);
return {
baseUrl: LOCAL_KILOCODE_BASE_URL,
api: "openai-completions",

View file

@ -13,6 +13,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
}),
}));
import { buildKilocodeProvider, buildKilocodeProviderWithDiscovery } from "./api.js";
import {
discoverKilocodeModels,
KILOCODE_DEFAULT_COST,
@ -23,18 +24,6 @@ type MockKilocodeFetch = ((url: string, init?: RequestInit) => Promise<Response>
mock: { calls: unknown[][] };
};
const EXPECTED_STATIC_KILOCODE_MODELS = [
{
id: "kilo-auto/balanced",
name: "Auto Balanced",
reasoning: true,
input: ["text", "image"],
cost: { input: 0.325, output: 1.95, cacheRead: 0.0325, cacheWrite: 0.40625 },
contextWindow: 1000000,
maxTokens: 65536,
},
];
function requireModelById(
models: Awaited<ReturnType<typeof discoverKilocodeModels>>,
id: string,
@ -145,6 +134,20 @@ afterAll(() => {
});
describe("discoverKilocodeModels (fetch path)", () => {
it.each([503, 200])(
"preserves the public advisory builder for HTTP %s with no rows",
async (status) => {
await withFetchPathTest(
vi.fn(async () => jsonResponse({ data: [] }, { status })),
async () => {
await expect(buildKilocodeProviderWithDiscovery()).resolves.toEqual(
buildKilocodeProvider(),
);
},
);
},
);
it("parses gateway models with correct pricing conversion", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
@ -255,38 +258,51 @@ describe("discoverKilocodeModels (fetch path)", () => {
},
);
it("falls back to static catalog on network error", async () => {
it("propagates network errors", async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error("network error"));
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(models).toStrictEqual(EXPECTED_STATIC_KILOCODE_MODELS);
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toThrow(
"network error",
);
});
});
it("falls back to static catalog on HTTP error", async () => {
it("releases the response before propagating an HTTP error", async () => {
const response = new Response("temporary failure", { status: 500 });
const cancelSpy = vi.spyOn(response.body!, "cancel").mockResolvedValue(undefined);
const mockFetch = vi.fn().mockResolvedValue(response);
const release = await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(models).toStrictEqual(EXPECTED_STATIC_KILOCODE_MODELS);
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toMatchObject({
status: 500,
});
});
expect(cancelSpy).toHaveBeenCalledOnce();
expect(release).toHaveBeenCalledOnce();
});
it("falls back to static catalog for malformed successful model list payloads", async () => {
for (const payload of [[], { data: {} }, { data: [null] }]) {
it("rejects malformed model list envelopes", async () => {
for (const payload of [[], { data: {} }]) {
const mockFetch = vi.fn().mockResolvedValue(jsonResponse(payload));
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(models).toStrictEqual(EXPECTED_STATIC_KILOCODE_MODELS);
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toThrow(
"Kilocode model list: malformed JSON response",
);
});
}
});
it.each([{ data: [] }, { data: [null] }])(
"does not restore seed models when no usable live rows remain: %j",
async (payload) => {
const mockFetch = vi.fn().mockResolvedValue(jsonResponse(payload));
await withFetchPathTest(mockFetch, async () => {
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).resolves.toEqual([]);
});
},
);
it("falls back from malformed live token metadata", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({

View file

@ -182,7 +182,7 @@ function projectKilocodeModels(rows: readonly unknown[]): ModelDefinitionConfig[
// A malformed row must not hide a later valid row with the same id.
}
}
for (const staticModel of buildStaticCatalog()) {
for (const staticModel of models.length > 0 ? buildStaticCatalog() : []) {
if (!discoveredIds.has(staticModel.id)) {
models.unshift(staticModel);
}
@ -190,8 +190,11 @@ function projectKilocodeModels(rows: readonly unknown[]): ModelDefinitionConfig[
return models;
}
export async function discoverKilocodeModels(): Promise<ModelDefinitionConfig[]> {
export async function discoverKilocodeModels(
options: { discoveryMode?: "strict" } = {},
): Promise<ModelDefinitionConfig[]> {
const provider = await buildLiveModelProviderConfig({
...options,
providerId: "kilocode",
endpoint: KILOCODE_MODELS_URL,
providerConfig: { baseUrl: KILOCODE_BASE_URL, api: "openai-completions" },

View file

@ -97,6 +97,7 @@ export default definePluginEntry({
order: "simple",
run: (ctx) =>
buildOpenAICompatibleProviderCatalog({
discoveryMode: "strict",
ctx,
providerId: PROVIDER_ID,
buildProvider: buildLitellmProvider,

View file

@ -24,7 +24,7 @@ export default defineSingleProviderPluginEntry({
noteTitle: "LongCat",
noteMessage: "Manage API keys at https://longcat.chat/platform/api_keys",
},
catalog: { liveModelDiscovery: true },
catalog: { liveModelDiscovery: true, discoveryMode: "strict" },
...buildProviderReplayFamilyHooks({
family: "openai-compatible",
dropReasoningFromHistory: false,

View file

@ -25,6 +25,7 @@ export default defineSingleProviderPluginEntry({
noteTitle: "Meta",
},
catalog: {
discoveryMode: "strict",
buildProvider: buildMetaProvider,
buildStaticProvider: buildMetaProvider,
liveModelDiscovery: true,

View file

@ -8,7 +8,7 @@ import {
requireRegisteredProvider,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { MINIMAX_OAUTH_MARKER } from "openclaw/plugin-sdk/provider-auth";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildMinimaxModelDiscovery } from "./provider-catalog.js";
import { registerMinimaxProviders } from "./provider-registration.js";
import { createMiniMaxWebSearchProvider } from "./src/minimax-web-search-provider.js";
@ -35,6 +35,13 @@ afterEach(() => {
});
describe("minimax provider hooks", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async () => Response.json({ data: [{ id: "MiniMax-M3" }] })),
);
});
it("uses the Anthropic model-list route and X-Api-Key auth", () => {
const discovery = buildMinimaxModelDiscovery();
const headers = new Headers(

View file

@ -14,7 +14,7 @@ import {
buildOauthProviderAuthResult,
} from "openclaw/plugin-sdk/provider-auth";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { buildOpenAICompatibleLiveProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import {
buildProviderReplayFamilyHooks,
@ -139,15 +139,15 @@ async function resolveApiCatalog(ctx: ProviderCatalogContext) {
if (!auth.apiKey) {
return null;
}
return {
provider: await buildOpenAICompatibleLiveModelProviderConfig({
providerId: API_PROVIDER_ID,
providerConfig: buildMinimaxProvider(ctx.env),
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
modelDiscovery: buildMinimaxModelDiscovery(),
}),
};
return await buildOpenAICompatibleLiveProviderCatalog({
discoveryMode: "strict",
providerId: API_PROVIDER_ID,
providerConfig: buildMinimaxProvider(ctx.env),
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
profileId: auth.profileId,
modelDiscovery: buildMinimaxModelDiscovery(),
});
}
async function resolvePortalCatalog(ctx: ProviderCatalogContext) {
@ -172,17 +172,20 @@ async function resolvePortalCatalog(ctx: ProviderCatalogContext) {
baseUrl: explicitBaseUrl || buildMinimaxPortalProvider(ctx.env).baseUrl,
apiKey,
});
return {
provider: await buildOpenAICompatibleLiveModelProviderConfig({
providerId: PORTAL_PROVIDER_ID,
providerConfig,
apiKey,
discoveryApiKey:
apiKeyAuth.discoveryApiKey ??
(usesPortalBearerAuth ? profileAuth.discoveryApiKey : undefined),
modelDiscovery: buildMinimaxModelDiscovery(usesPortalBearerAuth ? "oauth" : "api_key"),
}),
};
const discoveryAuth = apiKeyAuth.discoveryApiKey
? apiKeyAuth
: usesPortalBearerAuth
? profileAuth
: apiKeyAuth;
return await buildOpenAICompatibleLiveProviderCatalog({
discoveryMode: "strict",
providerId: PORTAL_PROVIDER_ID,
providerConfig,
apiKey,
discoveryApiKey: discoveryAuth.discoveryApiKey,
profileId: discoveryAuth.profileId,
modelDiscovery: buildMinimaxModelDiscovery(usesPortalBearerAuth ? "oauth" : "api_key"),
});
}
function createOAuthHandler(region: MiniMaxRegion) {

View file

@ -29,6 +29,7 @@ export default defineSingleProviderPluginEntry({
docsPath: "/providers/models",
manifestAuth: { applyConfig: applyMistralConfig },
catalog: {
discoveryMode: "strict",
allowExplicitBaseUrl: true,
liveModelDiscovery: true,
},

View file

@ -41,6 +41,7 @@ export default defineSingleProviderPluginEntry({
}),
],
catalog: {
discoveryMode: "strict",
buildProvider: buildMoonshotProvider,
buildStaticProvider: buildMoonshotProvider,
allowExplicitBaseUrl: true,

View file

@ -21,6 +21,7 @@ export default defineSingleProviderPluginEntry({
noteMessage: "Manage API keys at https://novita.ai/settings/key-management",
},
catalog: {
discoveryMode: "strict",
allowExplicitBaseUrl: true,
liveModelDiscovery: true,
},

View file

@ -500,7 +500,7 @@ describe("opencode-go provider plugin", () => {
}
});
it("caches both catalog requests and falls back to trusted seeds on failure", async () => {
it("caches both catalog requests without concealing later acquisition failure", async () => {
const liveIds = ["minimax-m3", "qwen3.7-max", "qwen3.7-plus"];
const fetchGuard = createCatalogFetchGuard({
upstreamModels: Object.fromEntries(liveIds.map((id) => [id, upstreamModel(id)])),
@ -525,15 +525,13 @@ describe("opencode-go provider plugin", () => {
clearLiveCatalogCacheForTests();
fetchGuard.mockRejectedValue(new Error("network unavailable"));
const fallback = await buildOpencodeGoLiveProviderConfig({
apiKey: "OPENCODE_API_KEY",
discoveryApiKey: "resolved-opencode-key",
fetchGuard,
});
expect(fallback.apiKey).toBe("OPENCODE_API_KEY");
expect(fallback.models.map((model) => model.id).toSorted()).toEqual(
ACTIVE_MODEL_IDS.toSorted(),
);
await expect(
buildOpencodeGoLiveProviderConfig({
apiKey: "OPENCODE_API_KEY",
discoveryApiKey: "resolved-opencode-key",
fetchGuard,
}),
).rejects.toThrow("network unavailable");
});
it.each([
@ -541,7 +539,7 @@ describe("opencode-go provider plugin", () => {
["retired seed", "filtered"],
["activated preview", "failed"],
] as const)(
"uses refreshed %s lifecycle on the first fallback after %s model advertising",
"keeps refreshed %s metadata separate from %s model advertising",
async (lifecycle, advertising) => {
const retired = lifecycle === "retired seed";
const modelId = retired ? "deepseek-v4-pro" : "hy3-preview";
@ -562,19 +560,17 @@ describe("opencode-go provider plugin", () => {
expect(buildStaticOpencodeGoProviderConfig().models.map((model) => model.id)).toEqual(
ACTIVE_MODEL_IDS,
);
const fallback = await buildOpencodeGoLiveProviderConfig({
const discovery = buildOpencodeGoLiveProviderConfig({
apiKey: "runtime-key",
discoveryApiKey: "discovery-key",
fetchGuard,
});
expect(fallback.apiKey).toBe("runtime-key");
expect(fallback.models.map((model) => model.id).toSorted()).toEqual(
(retired
? ACTIVE_MODEL_IDS.filter((id) => id !== modelId)
: [...ACTIVE_MODEL_IDS, modelId]
).toSorted(),
);
if (advertising === "failed") {
await expect(discovery).rejects.toThrow("model advertising unavailable");
} else {
await expect(discovery).resolves.toMatchObject({ models: [] });
}
expect(provider.resolveDynamicModel?.({ modelId } as never)).toMatchObject({
id: modelId,
});

View file

@ -1,4 +1,5 @@
// Opencode Go plugin entrypoint registers its OpenClaw integration.
import { runLiveProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
import { opencodeGoMediaUnderstandingProvider } from "./media-understanding-provider.js";
@ -17,7 +18,7 @@ import { resolveThinkingProfile } from "./provider-policy-api.js";
import { createOpencodeGoAttributionWrapper, createOpencodeGoWrapper } from "./stream.js";
const PROVIDER_ID = "opencode-go";
type OpencodeGoCatalogAuth = { apiKey?: string; discoveryApiKey?: string };
type OpencodeGoCatalogAuth = { apiKey?: string; discoveryApiKey?: string; profileId?: string };
function resolveOpencodeGoCatalogAuth(
resolveProviderApiKey: (providerId: string) => OpencodeGoCatalogAuth,
@ -107,12 +108,16 @@ export default defineSingleProviderPluginEntry({
provider: buildStaticOpencodeGoProviderConfig(auth.apiKey),
};
}
return {
provider: await buildOpencodeGoLiveProviderConfig({
apiKey: auth.apiKey ?? auth.discoveryApiKey,
discoveryApiKey: auth.discoveryApiKey,
return await runLiveProviderCatalog({
providerId: PROVIDER_ID,
profileId: auth.profileId,
run: async () => ({
provider: await buildOpencodeGoLiveProviderConfig({
apiKey: auth.apiKey ?? auth.discoveryApiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
}),
};
});
},
},
augmentModelCatalog: () => listOpencodeGoModelCatalogEntries(),

View file

@ -130,6 +130,7 @@ export async function buildOpencodeGoLiveProviderConfig(
// Keep the trusted offline seed usable when upstream metadata is unavailable.
}
return await buildLiveModelProviderConfig({
discoveryMode: "strict",
providerId: PROVIDER_ID,
endpoint: OPENCODE_GO_MODELS_ENDPOINT,
providerConfig: {

View file

@ -518,22 +518,21 @@ describe("opencode provider plugin", () => {
]);
});
it("retains the compact offline seed when discovery fails", async () => {
it("reports failed discovery instead of returning the offline seed", async () => {
const fetchGuard = vi.fn(async () => {
throw new Error("network unavailable");
});
const fallback = await buildOpencodeZenLiveProviderConfig({
apiKey: "runtime-key",
discoveryApiKey: "discovery-key",
fetchGuard,
});
expect(fallback.apiKey).toBe("runtime-key");
expectSeedModels(fallback.models);
await expect(
buildOpencodeZenLiveProviderConfig({
apiKey: "runtime-key",
discoveryApiKey: "discovery-key",
fetchGuard,
}),
).rejects.toThrow("network unavailable");
});
it.each(["failed", "filtered"] as const)(
"uses refreshed lifecycle on the first fallback after %s model advertising",
"keeps refreshed metadata separate from %s model advertising",
async (advertising) => {
const retiredId = "big-pickle";
const provider = await registerSingleProviderPlugin(plugin);
@ -549,16 +548,17 @@ describe("opencode provider plugin", () => {
try {
expectSeedModels((await buildOpencodeZenLiveProviderConfig()).models);
const fallback = await buildOpencodeZenLiveProviderConfig({
const discovery = buildOpencodeZenLiveProviderConfig({
apiKey: "runtime-key",
discoveryApiKey: "discovery-key",
fetchGuard,
});
expect(fallback.apiKey).toBe("runtime-key");
expect(fallback.models.map((model) => model.id)).toEqual(
OFFLINE_MODEL_IDS.filter((id) => id !== retiredId),
);
if (advertising === "failed") {
await expect(discovery).rejects.toThrow("model advertising unavailable");
} else {
await expect(discovery).resolves.toMatchObject({ models: [] });
}
expect(provider.resolveDynamicModel?.({ modelId: retiredId } as never)).toMatchObject({
id: retiredId,
});

View file

@ -1,4 +1,5 @@
// Opencode plugin entrypoint registers its OpenClaw integration.
import { runLiveProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import {
buildProviderReplayFamilyHooks,
@ -24,7 +25,7 @@ import { wrapOpencodeProviderStream } from "./stream.js";
const PROVIDER_ID = "opencode";
const MINIMAX_MODERN_MODEL_MATCHERS = ["minimax-m2.7"] as const;
type OpencodeZenCatalogAuth = { apiKey?: string; discoveryApiKey?: string };
type OpencodeZenCatalogAuth = { apiKey?: string; discoveryApiKey?: string; profileId?: string };
function resolveOpencodeZenCatalogAuth(
resolveProviderApiKey: (providerId: string) => OpencodeZenCatalogAuth,
@ -139,12 +140,16 @@ export default defineSingleProviderPluginEntry({
provider: buildStaticOpencodeZenProviderConfig(auth.apiKey),
};
}
return {
provider: await buildOpencodeZenLiveProviderConfig({
apiKey: auth.apiKey ?? auth.discoveryApiKey,
discoveryApiKey: auth.discoveryApiKey,
return await runLiveProviderCatalog({
providerId: PROVIDER_ID,
profileId: auth.profileId,
run: async () => ({
provider: await buildOpencodeZenLiveProviderConfig({
apiKey: auth.apiKey ?? auth.discoveryApiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
}),
};
});
},
staticRun: async () => ({ provider: buildStaticOpencodeZenProviderConfig() }),
},

View file

@ -140,6 +140,7 @@ export async function buildOpencodeZenLiveProviderConfig(
// The offline seed remains usable when authoritative metadata is unavailable.
}
return await buildLiveModelProviderConfig({
discoveryMode: "strict",
providerId: PROVIDER_ID,
endpoint: OPENCODE_ZEN_MODELS_ENDPOINT,
providerConfig: {

View file

@ -7,6 +7,7 @@ import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import { runLiveProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import {
buildProviderReplayFamilyHooks,
@ -285,14 +286,18 @@ export default defineSingleProviderPluginEntry({
return null;
}
const providerConfig = ctx.config.models?.providers?.openrouter;
return {
provider: await buildOpenrouterLiveProvider({
apiKey,
discoveryApiKey: auth.discoveryApiKey,
baseUrl: providerConfig?.baseUrl,
request: providerConfig?.request,
return await runLiveProviderCatalog({
providerId: PROVIDER_ID,
profileId: auth.profileId,
run: async () => ({
provider: await buildOpenrouterLiveProvider({
apiKey,
discoveryApiKey: auth.discoveryApiKey,
baseUrl: providerConfig?.baseUrl,
request: providerConfig?.request,
}),
}),
};
});
},
staticRun: async () => ({
provider: buildOpenrouterProvider(),

View file

@ -3,7 +3,7 @@ import {
type LiveModelCatalogFetchGuard,
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildOpenrouterLiveProvider, buildOpenrouterProvider } from "./provider-catalog.js";
import { buildOpenrouterLiveProvider } from "./provider-catalog.js";
describe("OpenRouter provider catalog", () => {
beforeEach(() => {
@ -256,23 +256,31 @@ describe("OpenRouter provider catalog", () => {
});
it("does not follow cross-origin catalog pagination with private credentials", async () => {
const release = vi.fn(async () => undefined);
const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({
response: Response.json({
data: [{ id: "private/model" }],
next: "https://attacker.example.invalid/models?page=2",
}),
finalUrl: url,
release: async () => undefined,
release,
}));
const provider = await buildOpenrouterLiveProvider({
apiKey: "synthetic-private-key",
baseUrl: "https://private.example.invalid/v1",
fetchGuard,
});
await expect(
buildOpenrouterLiveProvider({
apiKey: "synthetic-private-key",
baseUrl: "https://private.example.invalid/v1",
fetchGuard,
}),
).rejects.toThrow("did not include a supported next page");
expect(fetchGuard).toHaveBeenCalledOnce();
expect(provider.models).toEqual(buildOpenrouterProvider().models);
const request = vi.mocked(fetchGuard).mock.calls[0]?.[0];
expect(request?.url).toBe("https://private.example.invalid/v1/models");
expect(new Headers(request?.init?.headers).get("authorization")).toBe(
"Bearer synthetic-private-key",
);
expect(release).toHaveBeenCalledOnce();
});
it("strips private bearer and custom auth headers after a guarded cross-origin redirect", async () => {
@ -325,7 +333,7 @@ describe("OpenRouter provider catalog", () => {
expect(fetchGuard).not.toHaveBeenCalled();
});
it("caches live discovery and falls back to bundled rows", async () => {
it("caches live discovery and propagates acquisition failure", async () => {
const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({
response: Response.json({
data: [
@ -353,11 +361,12 @@ describe("OpenRouter provider catalog", () => {
clearLiveCatalogCacheForTests();
vi.mocked(fetchGuard).mockRejectedValueOnce(new Error("network unavailable"));
const fallback = await buildOpenrouterLiveProvider({
apiKey: "runtime-a",
discoveryApiKey: "discovery-a",
fetchGuard,
});
expect(fallback.models).toEqual(buildOpenrouterProvider().models);
await expect(
buildOpenrouterLiveProvider({
apiKey: "runtime-a",
discoveryApiKey: "discovery-a",
fetchGuard,
}),
).rejects.toThrow("network unavailable");
});
});

View file

@ -219,6 +219,7 @@ export async function buildOpenrouterLiveProvider(params: {
const requestConfig = resolveRequest();
const endpoint = `${requestConfig.baseUrl}/models`;
return await buildLiveModelProviderConfig({
discoveryMode: "strict",
providerId: "openrouter",
endpoint,
providerConfig: {

View file

@ -17,6 +17,6 @@ export default defineSingleProviderPluginEntry({
defaultModel: QIANFAN_DEFAULT_MODEL_REF,
applyConfig: applyQianfanConfig,
},
catalog: { liveModelDiscovery: true },
catalog: { liveModelDiscovery: true, discoveryMode: "strict" },
},
});

View file

@ -6,7 +6,7 @@ import {
} from "openclaw/plugin-sdk/plugin-test-runtime";
import type { ProviderCatalogResult } from "openclaw/plugin-sdk/provider-catalog-shared";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
QWEN_36_FLASH_MODEL_ID,
QWEN_36_PLUS_MODEL_ID,
@ -40,6 +40,14 @@ async function registerQwenProvider() {
}
describe("qwen provider plugin", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async () => Response.json({ data: [{ id: "qwen3.8-max" }, { id: "qwen3.8-flash" }] })),
);
});
afterEach(() => vi.unstubAllGlobals());
it("keeps Standard-only models out of Coding Plan normalized catalogs", async () => {
const provider = await registerQwenProvider();

View file

@ -1,6 +1,6 @@
// Qwen plugin entrypoint registers its OpenClaw integration.
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { buildOpenAICompatibleLiveProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { buildQwenMediaUnderstandingProvider } from "./media-understanding-provider.js";
@ -261,14 +261,14 @@ export default defineSingleProviderPluginEntry({
return null;
}
const baseUrl = resolveConfiguredQwenBaseUrl(ctx.config) ?? QWEN_BASE_URL;
return {
provider: await buildOpenAICompatibleLiveModelProviderConfig({
providerId: PROVIDER_ID,
providerConfig: buildQwenProvider({ baseUrl }),
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
};
return await buildOpenAICompatibleLiveProviderCatalog({
discoveryMode: "strict",
providerId: PROVIDER_ID,
providerConfig: buildQwenProvider({ baseUrl }),
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
profileId: auth.profileId,
});
},
staticRun: async () => ({ provider: buildQwenProvider() }),
},
@ -299,14 +299,14 @@ export default defineSingleProviderPluginEntry({
return null;
}
const baseUrl = resolveConfiguredQwenTokenPlanBaseUrl(ctx.config);
return {
provider: await buildOpenAICompatibleLiveModelProviderConfig({
providerId: QWEN_TOKEN_PLAN_PROVIDER_ID,
providerConfig: buildQwenTokenPlanProvider({ baseUrl }),
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
};
return await buildOpenAICompatibleLiveProviderCatalog({
discoveryMode: "strict",
providerId: QWEN_TOKEN_PLAN_PROVIDER_ID,
providerConfig: buildQwenTokenPlanProvider({ baseUrl }),
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
profileId: auth.profileId,
});
},
},
staticCatalog: {

View file

@ -4,7 +4,7 @@ import {
type ProviderCatalogContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { buildOpenAICompatibleLiveProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
applyStepFunPlanConfig,
@ -92,8 +92,9 @@ async function resolveStepFunCatalog(
ctx: ProviderCatalogContext,
params: { providerId: string; surface: StepFunSurface },
) {
const auth = ctx.resolveProviderAuth(params.providerId);
const apiKey = auth.apiKey ?? ctx.resolveProviderApiKey(params.providerId).apiKey;
const profileAuth = ctx.resolveProviderAuth(params.providerId);
const auth = profileAuth.apiKey ? profileAuth : ctx.resolveProviderApiKey(params.providerId);
const apiKey = auth.apiKey;
if (!apiKey) {
return null;
}
@ -109,14 +110,14 @@ async function resolveStepFunCatalog(
const baseUrl = explicitBaseUrl ?? resolveDefaultBaseUrl(params.surface, region ?? "intl");
const providerConfig =
params.surface === "plan" ? buildStepFunPlanProvider(baseUrl) : buildStepFunProvider(baseUrl);
return {
provider: await buildOpenAICompatibleLiveModelProviderConfig({
providerId: params.providerId,
providerConfig,
apiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
};
return await buildOpenAICompatibleLiveProviderCatalog({
discoveryMode: "strict",
providerId: params.providerId,
providerConfig,
apiKey,
discoveryApiKey: auth.discoveryApiKey,
profileId: auth.profileId,
});
}
function resolveProfileIds(region: StepFunRegion): [string, string] {

View file

@ -19,6 +19,7 @@ export default defineSingleProviderPluginEntry({
applyConfig: applySyntheticConfig,
},
catalog: {
discoveryMode: "strict",
buildProvider: buildSyntheticProvider,
buildStaticProvider: buildSyntheticProvider,
allowExplicitBaseUrl: true,

View file

@ -120,17 +120,28 @@ describe("Synthetic live catalog", () => {
);
});
it.each(["failure", "empty", "unusable"])("keeps offline seeds on %s discovery", async (mode) => {
if (mode === "failure") {
fetchGuard.mockRejectedValueOnce(new Error("fixture unavailable"));
} else {
respond(mode === "empty" ? [] : [{ id: "missing-metadata" }]);
}
const provider = await registerSingleProviderPlugin(plugin);
await expect(provider.catalog?.run(context())).resolves.toEqual({
provider: { ...buildSyntheticProvider(), apiKey: "SYNTHETIC_API_KEY" },
});
});
it.each(["failure", "empty", "unusable"])(
"does not substitute seeds for %s discovery",
async (mode) => {
if (mode === "failure") {
fetchGuard.mockRejectedValueOnce(new Error("fixture unavailable"));
} else {
respond(mode === "empty" ? [] : [{ id: "missing-metadata" }]);
}
const provider = await registerSingleProviderPlugin(plugin);
await expect(provider.catalog?.run(context())).resolves.toEqual(
mode === "failure"
? {
providers: {},
outcomes: [{ provider: "synthetic", status: "unavailable" }],
}
: {
provider: { ...buildSyntheticProvider(), apiKey: "SYNTHETIC_API_KEY", models: [] },
outcomes: [{ provider: "synthetic", status: "ready" }],
},
);
},
);
it("does not send a custom proxy credential to Synthetic's fixed model endpoint", async () => {
const provider = await registerSingleProviderPlugin(plugin);

View file

@ -81,6 +81,7 @@ export default definePluginEntry({
order: "simple",
run: (ctx) =>
buildOpenAICompatibleProviderCatalog({
discoveryMode: "strict",
ctx,
providerId: provider.providerId,
buildProvider: provider.buildProvider,

View file

@ -15,7 +15,7 @@ export default defineSingleProviderPluginEntry({
label: "Together",
docsPath: "/providers/together",
manifestAuth: { applyConfig: applyTogetherConfig },
catalog: { liveModelDiscovery: true },
catalog: { liveModelDiscovery: true, discoveryMode: "strict" },
classifyFailoverReason: ({ errorMessage }) =>
/\bconcurrency limit\b.*\b(?:breached|reached)\b/i.test(errorMessage)
? "rate_limit"

View file

@ -52,6 +52,7 @@ export default defineSingleProviderPluginEntry({
noteTitle: "Venice AI",
},
catalog: {
discoveryMode: "strict",
buildProvider: buildStaticVeniceProvider,
liveModelDiscovery: VENICE_MODEL_DISCOVERY_OPTIONS,
},

View file

@ -161,6 +161,6 @@ function projectVeniceModels(
export const VENICE_MODEL_DISCOVERY_OPTIONS = {
timeoutMs: VENICE_DISCOVERY_TIMEOUT_MS,
ttlMs: VENICE_DISCOVERY_CACHE_TTL_MS,
buildRequestHeaders: () => ({ Accept: "application/json" }),
authentication: "none",
projectRows: projectVeniceModels,
} as const;

View file

@ -24,7 +24,8 @@ export default defineSingleProviderPluginEntry({
applyConfig: applyVercelAiGatewayConfig,
},
catalog: {
buildProvider: buildVercelAiGatewayProvider,
discoveryMode: "strict",
buildProvider: () => buildVercelAiGatewayProvider({ discoveryMode: "strict" }),
buildStaticProvider: buildStaticVercelAiGatewayProvider,
},
resolveDynamicModel: ({ modelId }) => resolveVercelAiGatewayModel(modelId),

View file

@ -204,12 +204,11 @@ function buildDiscoveredModelDefinition(value: unknown): ModelDefinitionConfig |
};
}
export async function discoverVercelAiGatewayModels(): Promise<ModelDefinitionConfig[]> {
if (process.env.VITEST || process.env.NODE_ENV === "test") {
return getStaticVercelAiGatewayModelCatalog();
}
export async function discoverVercelAiGatewayModels(
options: { discoveryMode?: "strict" } = {},
): Promise<ModelDefinitionConfig[]> {
const provider = await buildLiveModelProviderConfig({
...options,
providerId: VERCEL_AI_GATEWAY_PROVIDER_ID,
endpoint: `${VERCEL_AI_GATEWAY_BASE_URL}/v1/models`,
providerConfig: {

View file

@ -34,27 +34,6 @@ const STATIC_MODEL_IDS = [
"moonshotai/kimi-k2.6",
];
function restoreEnvVar(name: "NODE_ENV" | "VITEST", value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
async function withLiveDiscovery<T>(run: () => Promise<T>): Promise<T> {
const oldNodeEnv = process.env.NODE_ENV;
const oldVitest = process.env.VITEST;
delete process.env.NODE_ENV;
delete process.env.VITEST;
try {
return await run();
} finally {
restoreEnvVar("NODE_ENV", oldNodeEnv);
restoreEnvVar("VITEST", oldVitest);
}
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
@ -69,15 +48,20 @@ afterEach(() => {
});
describe("vercel ai gateway provider catalog", () => {
it("builds the bundled Vercel AI Gateway defaults", async () => {
const provider = await buildVercelAiGatewayProvider();
expect(provider).toStrictEqual({
baseUrl: VERCEL_AI_GATEWAY_BASE_URL,
api: "anthropic-messages",
models: getStaticVercelAiGatewayModelCatalog(),
});
});
it.each([503, 200])(
"preserves the public advisory builder for HTTP %s with no rows",
async (status) => {
const release = vi.fn(async () => undefined);
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: jsonResponse({ data: [] }, { status }),
release,
});
await expect(buildVercelAiGatewayProvider()).resolves.toEqual(
buildStaticVercelAiGatewayProvider(),
);
expect(release).toHaveBeenCalledOnce();
},
);
it("exposes the static fallback model catalog", () => {
expect(getStaticVercelAiGatewayModelCatalog().map((model) => model.id)).toStrictEqual(
@ -154,20 +138,22 @@ describe("vercel ai gateway provider catalog", () => {
finalUrl: `${VERCEL_AI_GATEWAY_BASE_URL}/v1/models`,
});
await withLiveDiscovery(async () => {
expect((await buildVercelAiGatewayProvider()).models).toMatchObject([
{
id: "alibaba/qwen3-235b-a22b-thinking",
reasoning: true,
input: ["text", "image"],
},
{ id: "custom/legacy-model", input: ["text"] },
]);
});
expect((await buildVercelAiGatewayProvider()).models).toMatchObject([
{
id: "alibaba/qwen3-235b-a22b-thinking",
reasoning: true,
input: ["text", "image"],
},
{ id: "custom/legacy-model", input: ["text"] },
]);
});
it("falls back to the static catalog for malformed successful model list payloads", async () => {
for (const payload of [[], { data: {} }, { data: [null] }]) {
it("preserves empty and fully filtered catalogs", async () => {
for (const payload of [
[],
{ data: [] },
{ data: [{ id: "fixture/embedding-model", type: "embedding" }] },
]) {
clearLiveCatalogCacheForTests();
fetchWithSsrFGuardMock.mockReset();
fetchWithSsrFGuardMock.mockResolvedValueOnce({
@ -176,14 +162,32 @@ describe("vercel ai gateway provider catalog", () => {
finalUrl: `${VERCEL_AI_GATEWAY_BASE_URL}/v1/models`,
});
await withLiveDiscovery(async () => {
expect(await discoverVercelAiGatewayModels()).toStrictEqual(
getStaticVercelAiGatewayModelCatalog(),
);
});
await expect(discoverVercelAiGatewayModels({ discoveryMode: "strict" })).resolves.toEqual([]);
}
});
it.each([
{
kind: "envelope",
payload: { data: {} },
error: "Live model catalog response must be an array or { data: [] }",
},
{
kind: "row",
payload: { data: [null] },
error: "Vercel AI Gateway model list: malformed JSON response",
},
])("propagates a malformed model list $kind", async ({ payload, error }) => {
const release = vi.fn(async () => undefined);
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: jsonResponse(payload),
release,
finalUrl: `${VERCEL_AI_GATEWAY_BASE_URL}/v1/models`,
});
await expect(discoverVercelAiGatewayModels({ discoveryMode: "strict" })).rejects.toThrow(error);
expect(release).toHaveBeenCalledOnce();
});
it("falls back from malformed live token metadata", async () => {
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: jsonResponse({
@ -208,19 +212,17 @@ describe("vercel ai gateway provider catalog", () => {
finalUrl: `${VERCEL_AI_GATEWAY_BASE_URL}/v1/models`,
});
await withLiveDiscovery(async () => {
const models = await discoverVercelAiGatewayModels();
const models = await discoverVercelAiGatewayModels();
expect(models[0]).toMatchObject({
id: "anthropic/claude-opus-4.6",
contextWindow: 1_000_000,
maxTokens: 128_000,
});
expect(models[1]).toMatchObject({
id: "custom/provider-model",
contextWindow: VERCEL_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW,
maxTokens: VERCEL_AI_GATEWAY_DEFAULT_MAX_TOKENS,
});
expect(models[0]).toMatchObject({
id: "anthropic/claude-opus-4.6",
contextWindow: 1_000_000,
maxTokens: 128_000,
});
expect(models[1]).toMatchObject({
id: "custom/provider-model",
contextWindow: VERCEL_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW,
maxTokens: VERCEL_AI_GATEWAY_DEFAULT_MAX_TOKENS,
});
});
@ -232,11 +234,9 @@ describe("vercel ai gateway provider catalog", () => {
finalUrl: `${VERCEL_AI_GATEWAY_BASE_URL}/v1/models`,
});
await withLiveDiscovery(async () => {
const models = await discoverVercelAiGatewayModels();
const models = await discoverVercelAiGatewayModels();
expect(models.map((model) => model.id)).toStrictEqual(["custom/live-model"]);
});
expect(models.map((model) => model.id)).toStrictEqual(["custom/live-model"]);
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
mode: "trusted_env_proxy",

View file

@ -49,10 +49,12 @@ export function buildStaticVercelAiGatewayProvider(): ModelProviderConfig {
};
}
export async function buildVercelAiGatewayProvider(): Promise<ModelProviderConfig> {
export async function buildVercelAiGatewayProvider(
options: { discoveryMode?: "strict" } = {},
): Promise<ModelProviderConfig> {
return {
baseUrl: VERCEL_AI_GATEWAY_BASE_URL,
api: "anthropic-messages",
models: await discoverVercelAiGatewayModels(),
models: await discoverVercelAiGatewayModels(options),
};
}

View file

@ -29,6 +29,7 @@ export default defineSingleProviderPluginEntry({
ensureModelAllowlistEntry({ cfg, modelRef: VOLCENGINE_DEFAULT_MODEL_REF }),
},
...buildOpenAICompatibleProviderFamilyCatalog({
discoveryMode: "strict",
credentialProviderId: PROVIDER_ID,
entries: VOLCENGINE_PROVIDER_CATALOG.entries,
staticCatalog: VOLCENGINE_PROVIDER_CATALOG.staticCatalog,

View file

@ -172,7 +172,9 @@ async function runXaiCatalog(options: { auth?: "none"; apiKey?: false } = {}) {
describe("xai provider plugin", () => {
beforeEach(() => {
clearLiveCatalogCacheForTests();
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockReset();
providerAuthRuntimeMocks.resolveApiKeyForProvider
.mockReset()
.mockRejectedValue(new Error("No runtime credential"));
vi.stubEnv("XAI_API_KEY", "");
});
@ -437,16 +439,30 @@ describe("xai provider plugin", () => {
});
});
it("keeps the Grok OAuth transport when xAI OAuth discovery is unavailable", async () => {
it("reports OAuth discovery failure without retrying with an API key", async () => {
mockXaiRuntimeOAuth();
stubXaiFetch(() => new Response("temporarily unavailable", { status: 503 }));
const { result } = await runXaiCatalog();
const fetchMock = stubXaiFetch(() => new Response("temporarily unavailable", { status: 503 }));
const provider = await registerSingleProviderPlugin(plugin);
const resolveProviderApiKey = vi.fn(() => ({ apiKey: "alternate-key" }));
const result = await provider.catalog?.run({
config: {},
env: {},
resolveProviderApiKey,
resolveProviderAuth: () => ({ apiKey: undefined, mode: "none", source: "none" }),
});
expect(result.baseUrl).toBe("https://cli-chat-proxy.grok.com/v1");
expect(result.auth).toBe("oauth");
expect(result.apiKey).toBeUndefined();
expect(result.models.map((model) => model.id)).toContain("auto");
expect(result.models.map((model) => model.id)).toContain("grok-build-0.1");
expect(result).toEqual({
providers: {},
outcomes: [{ provider: "xai", profileId: "xai-profile", status: "unavailable" }],
});
expect(resolveProviderApiKey).not.toHaveBeenCalled();
expect(
fetchMock.mock.calls.every(([url]) =>
(url instanceof Request ? url.url : url.toString()).startsWith(
"https://cli-chat-proxy.grok.com/",
),
),
).toBe(true);
});
it("falls back to API-key discovery when xAI OAuth credential resolution fails", async () => {

View file

@ -1,6 +1,7 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
// Xai plugin entrypoint registers its OpenClaw integration.
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
import { runLiveProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
import { defaultToolStreamExtraParams } from "openclaw/plugin-sdk/provider-stream-shared";
@ -203,53 +204,32 @@ export default defineSingleProviderPluginEntry({
order: "simple",
run: async (ctx) => {
const auth = ctx.resolveProviderAuth(PROVIDER_ID);
try {
const { resolveApiKeyForProvider } =
await import("openclaw/plugin-sdk/provider-auth-runtime");
const runtimeAuth = await resolveApiKeyForProvider({
provider: PROVIDER_ID,
cfg: ctx.config,
...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}),
...(ctx.workspaceDir ? { workspaceDir: ctx.workspaceDir } : {}),
...(auth.profileId
? {
profileId: auth.profileId,
lockedProfile: true,
}
: {}),
});
if (runtimeAuth?.mode === "oauth" && runtimeAuth.apiKey) {
return {
provider: await buildLiveXaiOAuthProvider({
discoveryApiKey: runtimeAuth.apiKey,
}),
};
}
} catch {
if (auth.mode === "oauth") {
// OAuth discovery is advisory; fall through so configured API-key
// auth can still publish the standard xAI catalog.
}
}
if (auth.apiKey) {
return {
provider: await buildLiveXaiProvider({
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
};
}
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID);
if (!apiKey.apiKey) {
const { resolveApiKeyForProvider } =
await import("openclaw/plugin-sdk/provider-auth-runtime");
const runtimeAuth = await resolveApiKeyForProvider({
provider: PROVIDER_ID,
cfg: ctx.config,
...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}),
...(ctx.workspaceDir ? { workspaceDir: ctx.workspaceDir } : {}),
...(auth.profileId ? { profileId: auth.profileId, lockedProfile: true } : {}),
}).catch(() => undefined);
const selectedAuth =
runtimeAuth?.mode === "oauth" && runtimeAuth.apiKey
? { ...runtimeAuth, oauth: true }
: { ...(auth.apiKey ? auth : ctx.resolveProviderApiKey(PROVIDER_ID)), oauth: false };
if (!selectedAuth.apiKey) {
return null;
}
return {
provider: await buildLiveXaiProvider({
apiKey: apiKey.apiKey,
discoveryApiKey: apiKey.discoveryApiKey,
const apiKey = selectedAuth.apiKey;
return await runLiveProviderCatalog({
providerId: PROVIDER_ID,
profileId: selectedAuth.profileId,
run: async () => ({
provider: selectedAuth.oauth
? await buildLiveXaiOAuthProvider({ discoveryApiKey: apiKey })
: await buildLiveXaiProvider(selectedAuth),
}),
};
});
},
staticRun: async () => ({
provider: buildXaiProvider(),

View file

@ -155,6 +155,7 @@ export async function buildLiveXaiProvider(params: {
signal?: AbortSignal;
}): Promise<ModelProviderConfig> {
return await buildLiveModelProviderConfig({
discoveryMode: "strict",
providerId: PROVIDER_ID,
endpoint: XAI_MODELS_ENDPOINT,
providerConfig: {
@ -251,6 +252,7 @@ export async function buildLiveXaiOAuthProvider(params: {
const fallback = buildXaiOAuthFallbackProvider();
const [provider, preferredModelId] = await Promise.all([
buildLiveModelProviderConfig({
discoveryMode: "strict",
providerId: PROVIDER_ID,
endpoint: XAI_GROK_OAUTH_MODELS_ENDPOINT,
providerConfig: {

View file

@ -274,7 +274,7 @@ describe("xiaomi provider plugin", () => {
});
it("exposes Token Plan v2.5 catalog rows only after a provider config selects a region", async () => {
const response = Response.json({ data: [] });
const response = Response.json({ data: [{ id: "mimo-v2.5" }, { id: "mimo-v2.5-pro" }] });
const release = vi.fn(async () => undefined);
const guardedFetch = vi.spyOn(ssrfRuntime, "fetchWithSsrFGuard").mockResolvedValue({
response,
@ -323,8 +323,8 @@ describe("xiaomi provider plugin", () => {
expect(configured.provider.baseUrl).toBe("https://token-plan-cn.xiaomimimo.com/v1");
expect(configured.provider.api).toBe("openai-completions");
expect(configured.provider.models?.map((model) => model.id)).toEqual([
"mimo-v2.5-pro",
"mimo-v2.5",
"mimo-v2.5-pro",
]);
expect(configured.provider.models?.find((model) => model.id === "mimo-v2.5")?.input).toEqual([
"text",

View file

@ -19,7 +19,7 @@ import {
upsertAuthProfileWithLockOrThrow,
validateApiKeyInput,
} from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { buildOpenAICompatibleLiveProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import {
applyModelCompatPatch,
buildProviderReplayFamilyHooks,
@ -105,17 +105,17 @@ async function resolveXiaomiCatalog(params: {
if (params.requireBaseUrl === true && !explicitBaseUrl) {
return null;
}
return {
provider: await buildOpenAICompatibleLiveModelProviderConfig({
providerId: params.providerId,
providerConfig: {
...params.buildProvider(),
...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}),
},
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
};
return await buildOpenAICompatibleLiveProviderCatalog({
discoveryMode: "strict",
providerId: params.providerId,
providerConfig: {
...params.buildProvider(),
...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}),
},
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
profileId: auth.profileId,
});
}
function buildXiaomiKeyMismatchMessage(params: {

View file

@ -349,7 +349,7 @@ export default defineSingleProviderPluginEntry({
endpoint: "cn",
}),
],
catalog: { allowExplicitBaseUrl: true, liveModelDiscovery: true },
catalog: { allowExplicitBaseUrl: true, liveModelDiscovery: true, discoveryMode: "strict" },
resolveDynamicModel: (ctx) => resolveGlm5ForwardCompatModel(ctx),
matchesContextOverflowError: ({ errorMessage }) =>
/\b(?:tokens? in request more than max tokens? allowed|prompt exceeds max(?:imum)? length)\b/i.test(

View file

@ -72,11 +72,16 @@ const unconfiguredProvider = {
staticCatalog: { run: vi.fn() },
};
function createMetadataSnapshot(pluginIds: string[]): PluginMetadataSnapshot {
function createMetadataSnapshot(
pluginIds: string[],
withDiscoveryEntry = true,
): PluginMetadataSnapshot {
const plugins = pluginIds.map((id) => ({
id,
origin: "bundled" as const,
providerDiscoverySource: `/fixtures/${id}/provider-discovery.ts`,
...(withDiscoveryEntry
? { providerDiscoverySource: `/fixtures/${id}/provider-discovery.ts` }
: {}),
}));
return {
index: {
@ -287,33 +292,58 @@ describe("prepared bundled provider static catalogs", () => {
expect(mocks.runProviderStaticCatalog).not.toHaveBeenCalled();
});
it("runs hooks omitted from a configured-only prepared catalog on full load", async () => {
mocks.runProviderStaticCatalog.mockResolvedValue({ marker: "full-static-result" });
mocks.normalizePluginDiscoveryResult.mockReturnValue({
google: {
models: [{ id: "gemini-3.1-pro-preview", contextWindow: 1_048_576 }],
},
});
it.each(["prepared", "registered"])(
"uses %s static hooks without a discovery entry",
async (source) => {
mocks.runProviderStaticCatalog.mockResolvedValue({ marker: "full-static-result" });
mocks.normalizePluginDiscoveryResult.mockReturnValue({
google: {
models: [{ id: "gemini-3.1-pro-preview", contextWindow: 1_048_576 }],
},
});
await expect(
loadBundledProviderStaticCatalogContextModels({
cfg,
metadataSnapshot: createMetadataSnapshot(["google"], false),
registeredProviders:
source === "registered"
? [
{
pluginId: "google",
provider: { ...provider, pluginId: "not-the-owner" },
source: "fixture",
},
]
: [],
preparedStaticProviderCatalog: {
providers: source === "prepared" ? [provider] : [],
entries: [],
},
}),
).resolves.toEqual([
expect.objectContaining({
id: "gemini-3.1-pro-preview",
provider: "google",
}),
]);
expect(mocks.resolveRuntimePluginDiscoveryProviders).not.toHaveBeenCalled();
expect(mocks.runProviderStaticCatalog).toHaveBeenCalledWith(
expect.objectContaining({ provider }),
);
expect(mocks.runProviderStaticCatalog).toHaveBeenCalledOnce();
},
);
it("does not activate an unknown runtime-only plugin to collect static rows", async () => {
await expect(
loadBundledProviderStaticCatalogContextModels({
cfg,
metadataSnapshot: createMetadataSnapshot(["google"]),
preparedStaticProviderCatalog: {
providers: [provider],
entries: [],
},
metadataSnapshot: createMetadataSnapshot(["google"], false),
}),
).resolves.toEqual([
expect.objectContaining({
id: "gemini-3.1-pro-preview",
provider: "google",
}),
]);
).resolves.toEqual([]);
expect(mocks.resolveRuntimePluginDiscoveryProviders).not.toHaveBeenCalled();
expect(mocks.runProviderStaticCatalog).toHaveBeenCalledWith(
expect.objectContaining({ provider }),
);
expect(mocks.runProviderStaticCatalog).not.toHaveBeenCalled();
});
it("discovers unconfigured providers when the full catalog is requested", async () => {

View file

@ -28,6 +28,8 @@ import {
resolveBundledProviderCompatPluginIds,
resolveOwningPluginIdsForProviderRef,
} from "../../plugins/providers.js";
import type { PluginRegistry } from "../../plugins/registry-types.js";
import { dedupeByKey } from "../../shared/dedupe-by-key.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { buildInlineProviderModels, type InlineModelEntry } from "./model.inline-provider.js";
import type { BundledStaticCatalogState } from "./model.static-catalog.types.js";
@ -437,9 +439,11 @@ async function loadBundledProviderStaticCatalogModels(params: {
return modelsByProvider;
}
/** Loads all enabled bundled provider static-catalog rows without live discovery or writes. */
/** Reads static rows from discovery entries and captured owners without activating runtimes. */
export async function loadBundledProviderStaticCatalogContextModels(
params: BundledProviderStaticCatalogResolverParams = {},
params: BundledProviderStaticCatalogResolverParams & {
registeredProviders?: Readonly<PluginRegistry["providers"]>;
} = {},
): Promise<ProviderRuntimeModel[]> {
const env = params.env ?? process.env;
const metadataSnapshot = resolveBundledStaticCatalogMetadataSnapshot({
@ -448,7 +452,19 @@ export async function loadBundledProviderStaticCatalogContextModels(
...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}),
workspaceDir: params.workspaceDir,
});
const discoveryEntryPluginIds = new Set(
const preparedStaticProviderCatalog = {
providers: dedupeByKey(
[
...(params.preparedStaticProviderCatalog?.providers ?? []),
...(params.registeredProviders ?? []).map(({ pluginId, provider }) =>
Object.assign({}, provider, { pluginId }),
),
],
(provider) => `${provider.pluginId}\0${provider.id}`,
),
entries: params.preparedStaticProviderCatalog?.entries ?? [],
};
const staticCatalogPluginIds = new Set(
(
metadataSnapshot?.manifestRegistry?.plugins ??
loadPluginManifestRegistryCore({
@ -460,6 +476,11 @@ export async function loadBundledProviderStaticCatalogContextModels(
plugin.origin === "bundled" && plugin.providerDiscoverySource ? [plugin.id] : [],
),
);
for (const provider of preparedStaticProviderCatalog.providers) {
if (provider.pluginId && provider.staticCatalog) {
staticCatalogPluginIds.add(provider.pluginId);
}
}
const providerScopedPluginIds = params.providerIds?.flatMap((provider) =>
resolveBundledProviderStaticCatalogPluginIds({
provider,
@ -479,7 +500,7 @@ export async function loadBundledProviderStaticCatalogContextModels(
})
: providerScopedPluginIds;
const pluginIds = [...new Set(candidatePluginIds)]
.filter((pluginId) => discoveryEntryPluginIds.has(pluginId))
.filter((pluginId) => staticCatalogPluginIds.has(pluginId))
.toSorted((left, right) => left.localeCompare(right));
if (pluginIds.length === 0) {
return [];
@ -492,9 +513,7 @@ export async function loadBundledProviderStaticCatalogContextModels(
cfg: params.cfg,
workspaceDir: params.workspaceDir,
env,
...(params.preparedStaticProviderCatalog
? { preparedStaticProviderCatalog: params.preparedStaticProviderCatalog }
: {}),
preparedStaticProviderCatalog,
...(metadataSnapshot ? { providerMetadataOwners: metadataSnapshot.owners } : {}),
...(metadataSnapshot ? { pluginMetadataSnapshot: metadataSnapshot } : {}),
}),

View file

@ -285,7 +285,11 @@ export async function runPreparedModelCatalogWorkerRequest(
value.preferBuiltPluginArtifacts,
);
pluginGenerationScope.pluginRegistry = catalogRegistry;
catalogGeneration = Object.freeze({ ...catalogGeneration, pluginRegistry: catalogRegistry });
catalogGeneration = Object.freeze({
...catalogGeneration,
pluginRegistry: catalogRegistry,
providerStaticModels: undefined,
});
}
const source = await prepareAgentCatalogSource(
exactAgentFacts,

View file

@ -373,6 +373,7 @@ export async function prepareWorkspaceBuildGroup(
cfg: input.config,
env,
metadataSnapshot: pluginMetadataSnapshot,
registeredProviders: runtimePluginRegistry?.providers,
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
}));
// Provider definitions are process/config facts. Which refs are admitted remains agent-owned.

View file

@ -65,6 +65,7 @@ export async function prepareFullCatalogFacts(
cfg: input.config,
env,
metadataSnapshot: pluginMetadataSnapshot,
registeredProviders: pluginGeneration.pluginRegistry?.providers,
...(preparedStaticProviderCatalog ? { preparedStaticProviderCatalog } : {}),
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
}));
@ -117,6 +118,14 @@ export function prepareModelCatalogPublication(
}
const previous = inventory?.catalog;
const previousAuth = previous && getPreparedModelFullCatalogAuth(previous);
const starterProviders = new Set(
failed
.map(({ provider }) => normalizeProvider(provider))
.filter((provider) => !discoveryOrigins.some((origin) => origin.provider === provider)),
);
const starters = (catalog.staticEntries ?? []).filter(
(entry) => !entry.nativeRuntime && starterProviders.has(normalizeProvider(entry.provider)),
);
const retainedProviders = new Set(
failed.flatMap((outcome) => {
const provider = normalizeProvider(outcome.provider);
@ -166,7 +175,9 @@ export function prepareModelCatalogPublication(
) =>
dedupeByKey(
[
...current.filter((entry) => !retainedProviders.has(normalizeProvider(entry.provider))),
...[...current, ...starters].filter(
(entry) => !retainedProviders.has(normalizeProvider(entry.provider)),
),
...retained.filter(
(entry) =>
!entry.nativeRuntime && retainedProviders.has(normalizeProvider(entry.provider)),

View file

@ -80,8 +80,9 @@ describe("prepared model runtime scoped refresh", () => {
};
const snapshots: ModelCatalogSnapshot[] = [
{
entries: [fallback],
routeVariants: [fallback],
entries: [],
routeVariants: [],
staticEntries: [fallback],
providerOutcomes: [{ provider: "provider-a", profileId, status: "unavailable" }],
},
{
@ -94,6 +95,7 @@ describe("prepared model runtime scoped refresh", () => {
{
entries: [fallback, sibling],
routeVariants: [fallback, sibling],
staticEntries: [fallback],
providerOutcomes: [
{ provider: "provider-a", profileId, status: "unavailable" },
{ provider: "provider-b", status: "ready" },
@ -117,7 +119,12 @@ describe("prepared model runtime scoped refresh", () => {
},
];
const owner = await prepareCatalogOwner(config, snapshots);
expect((await owner.loadFullModelCatalog!()).entries).toMatchObject([fallback]);
expect(await owner.loadFullModelCatalog!()).toMatchObject({
entries: [fallback],
routeVariants: [fallback],
authoritative: false,
providerOutcomes: snapshots[0]!.providerOutcomes,
});
await owner.loadFullModelCatalog!({ refresh: true });
const failed = await owner.loadFullModelCatalog!({ refresh: true });
expect(failed.entries).toMatchObject([learned, sibling]);
@ -147,6 +154,7 @@ describe("prepared model runtime scoped refresh", () => {
async (change) => {
const config: OpenClawConfig = { agents: { entries: { pro: {} } } };
const learned = { provider: "demo", id: "learned", name: "Learned" };
const starter = { provider: "demo", id: "starter", name: "Starter" };
const profiles = {
"demo:first": { type: "api_key" as const, provider: "demo", key: "first-key" },
"demo:second": { type: "api_key" as const, provider: "demo", key: "second-key" },
@ -165,6 +173,7 @@ describe("prepared model runtime scoped refresh", () => {
const failed: ModelCatalogSnapshot = {
entries: [],
routeVariants: [],
staticEntries: [starter],
providerOutcomes: [
{
provider: "demo",
@ -208,8 +217,8 @@ describe("prepared model runtime scoped refresh", () => {
const owner = await prepareCatalogOwner(config, [previous, failed]);
await owner.loadFullModelCatalog!();
expect(await owner.loadFullModelCatalog!({ refresh: true })).toMatchObject({
entries: [],
routeVariants: [],
entries: [starter],
routeVariants: [starter],
authoritative: false,
});
},

View file

@ -21,7 +21,7 @@ export type PreparedModelRuntimePluginGeneration = Readonly<{
messageToolCatalog?: PreparedMessageToolCatalog;
mediaCapabilityProviders?: ReturnType<typeof prepareMediaCapabilityProviders>;
preparedStaticProviderCatalog?: PreparedProviderStaticCatalog;
/** Present for live generations, including when the resolved model set is empty. */
/** Captured static rows; cleared when catalog discovery expands the provider registry. */
providerStaticModels?: readonly ProviderRuntimeModel[];
inlineProviderModels: readonly InlineModelEntry[];
configuredCatalogEntries: readonly ModelCatalogEntry[];

View file

@ -0,0 +1,458 @@
import { normalizeOptionalString as readLiveModelCatalogString } from "../../packages/normalization-core/src/string-coerce.js";
import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js";
import { readProviderJsonResponse } from "../agents/provider-http-errors.js";
import { cancelUnreadResponseBody } from "../infra/http-body.js";
import { retainSafeHeadersForCrossOriginRedirect } from "../infra/net/redirect-headers.js";
import {
isUpstreamProviderCatalogModel,
readLiveModelCatalogId,
readLiveModelCatalogRecord,
readLiveModelCatalogStringField,
type UpstreamProviderCatalog,
type UpstreamProviderCatalogModel,
} from "./provider-catalog-live-normalize.internal.js";
import { LiveModelCatalogHttpError } from "./provider-catalog-live-outcome.internal.js";
import { getCachedLiveCatalogValue } from "./provider-catalog-shared.js";
import type { ModelDefinitionConfig, ModelProviderConfig } from "./provider-model-shared.js";
import {
fetchWithSsrFGuard,
type LookupFn,
ssrfPolicyFromHttpBaseUrlAllowedHostname,
type SsrFPolicy,
} from "./ssrf-runtime.js";
export type LiveModelCatalogFetchGuard = typeof fetchWithSsrFGuard;
export type LiveModelCatalogHeaderContext = {
apiKey?: string;
discoveryApiKey?: string;
};
export type FetchLiveProviderModelIdsParams = {
providerId: string;
endpoint: string;
apiKey?: string;
discoveryApiKey?: string;
fetchGuard?: LiveModelCatalogFetchGuard;
signal?: AbortSignal;
timeoutMs?: number;
auditContext?: string;
policy?: SsrFPolicy;
lookupFn?: LookupFn;
requireHttps?: boolean;
readRows?: (body: unknown) => readonly unknown[];
readModelId?: (row: unknown) => string | undefined;
buildRequestHeaders?: (ctx: LiveModelCatalogHeaderContext) => HeadersInit;
};
export type FetchLiveProviderModelRowsParams = Omit<FetchLiveProviderModelIdsParams, "readModelId">;
export type CachedLiveProviderModelRowsParams = FetchLiveProviderModelRowsParams & {
ttlMs?: number;
cacheKeyParts?: readonly unknown[];
shouldCacheRows?: (rows: readonly unknown[]) => boolean;
};
export type GetCachedUpstreamProviderCatalogParams = {
endpoint: string;
providerId: string;
fetchGuard?: LiveModelCatalogFetchGuard;
signal?: AbortSignal;
timeoutMs?: number;
ttlMs?: number;
};
export type LiveModelRowProjection<T extends ModelDefinitionConfig = ModelDefinitionConfig> = (
rows: readonly unknown[],
fallback: ModelProviderConfig,
) => readonly T[];
// Live model catalogs are fetched at runtime from provider-controlled endpoints,
// so the success body is untrusted just like the error body. A faulty or hostile
// provider can stream an unbounded JSON document; reading it without a ceiling
// lets a single discovery call exhaust process memory. The cap is sized well
// above the largest known catalog (OpenRouter's live catalog is already >100KB
// and grows) while still bounding memory, matching the existing bounded reads
// for provider error bodies.
const LIVE_MODEL_CATALOG_BODY_MAX_BYTES = 4 * 1024 * 1024;
// Shared upstream feeds cover many providers and already exceed the ordinary
// single-provider ceiling; bound this explicitly without weakening that limit.
const UPSTREAM_PROVIDER_CATALOG_BODY_MAX_BYTES = 8 * 1024 * 1024;
const LIVE_MODEL_CATALOG_MAX_PAGES = 50;
function readDefaultLiveModelCatalogRows(body: unknown): readonly unknown[] {
if (Array.isArray(body)) {
return body;
}
// SAFETY: The JSON object guard permits reading an optional unknown data field.
if (body && typeof body === "object" && Array.isArray((body as { data?: unknown }).data)) {
// SAFETY: The array check above narrows the decoded JSON data field.
return (body as { data: unknown[] }).data;
}
throw new Error("Live model catalog response must be an array or { data: [] }");
}
function normalizeLiveModelCatalogRequestApiKey(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed || isNonSecretApiKeyMarker(trimmed)) {
return undefined;
}
return trimmed;
}
function selectLiveModelCatalogRequestApiKey(
ctx: LiveModelCatalogHeaderContext,
): string | undefined {
return (
// Explicit discovery credentials are resolved bytes; only apiKey can be a placeholder.
readLiveModelCatalogString(ctx.discoveryApiKey) ??
normalizeLiveModelCatalogRequestApiKey(ctx.apiKey)
);
}
function buildDefaultLiveModelCatalogHeaders(ctx: LiveModelCatalogHeaderContext): HeadersInit {
const requestApiKey = selectLiveModelCatalogRequestApiKey(ctx);
return {
Accept: "application/json",
...(requestApiKey ? { Authorization: `Bearer ${requestApiKey}` } : {}),
};
}
function buildHeaders(
params: FetchLiveProviderModelIdsParams,
safeReplayHeaders?: Headers,
): Headers {
const headers = safeReplayHeaders
? new Headers(safeReplayHeaders)
: new Headers(
(params.buildRequestHeaders ?? buildDefaultLiveModelCatalogHeaders)({
apiKey: normalizeLiveModelCatalogRequestApiKey(params.apiKey),
discoveryApiKey: selectLiveModelCatalogRequestApiKey(params),
}),
);
if (!headers.has("accept")) {
headers.set("accept", "application/json");
}
return headers;
}
async function readLiveModelCatalogJson(
response: Response,
params: { label: string; timeoutMs: number; maxBytes?: number; requestHeaders?: HeadersInit },
): Promise<unknown> {
return await readProviderJsonResponse(response, params.label, {
chunkTimeoutMs: params.timeoutMs,
maxBytes: params.maxBytes ?? LIVE_MODEL_CATALOG_BODY_MAX_BYTES,
requestHeaders: params.requestHeaders,
onOverflow: ({ size, maxBytes }) =>
new Error(`Live model catalog response exceeded ${maxBytes} bytes (${size} bytes received)`),
onIdleTimeout: ({ chunkTimeoutMs }) =>
new Error(`Live model catalog response stalled: no data received for ${chunkTimeoutMs}ms`),
});
}
/** Loads one provider from a shared public metadata feed only when explicitly requested. */
export async function getCachedUpstreamProviderCatalog(
params: GetCachedUpstreamProviderCatalogParams,
): Promise<UpstreamProviderCatalog | undefined> {
const body = await getCachedLiveCatalogValue({
// Provider ids intentionally stay out of this key: sibling providers share
// one upstream document and must not download it once per provider.
keyParts: ["upstream-provider-catalog", params.endpoint],
ttlMs: params.ttlMs ?? 300_000,
load: async () => {
const timeoutMs = params.timeoutMs ?? 15_000;
const { response, release } = await (params.fetchGuard ?? fetchWithSsrFGuard)({
url: params.endpoint,
init: { headers: { Accept: "application/json" } },
signal: params.signal,
timeoutMs,
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(params.endpoint),
requireHttps: true,
auditContext: "upstream-provider-catalog-discovery",
});
try {
if (!response.ok) {
await cancelUnreadResponseBody(response);
throw new LiveModelCatalogHttpError("upstream-provider-catalog", response.status);
}
const catalog = readLiveModelCatalogRecord(
await readLiveModelCatalogJson(response, {
label: "upstream-provider-catalog",
timeoutMs,
maxBytes: UPSTREAM_PROVIDER_CATALOG_BODY_MAX_BYTES,
}),
);
if (!catalog) {
throw new Error("Upstream provider catalog response must be an object");
}
return catalog;
} finally {
await release();
}
},
});
const provider = readLiveModelCatalogRecord(body[params.providerId]);
const models = readLiveModelCatalogRecord(provider?.models);
if (
!provider ||
!models ||
readLiveModelCatalogStringField(provider, "id") !== params.providerId
) {
return undefined;
}
return {
id: params.providerId,
...(readLiveModelCatalogStringField(provider, "api")
? { api: readLiveModelCatalogStringField(provider, "api") }
: {}),
...(readLiveModelCatalogStringField(provider, "npm")
? { npm: readLiveModelCatalogStringField(provider, "npm") }
: {}),
models: Object.fromEntries(
Object.entries(models).filter((entry): entry is [string, UpstreamProviderCatalogModel] =>
isUpstreamProviderCatalogModel(entry[1]),
),
),
};
}
function readLiveModelCatalogNextUrl(body: unknown): string | undefined {
const record = readLiveModelCatalogRecord(body);
if (!record) {
return undefined;
}
const links = readLiveModelCatalogRecord(record.links);
return readLiveModelCatalogString(record.next) ?? readLiveModelCatalogString(links?.next);
}
function readLiveModelCatalogCursor(
body: unknown,
): { name: "after" | "after_id" | "pageToken" | "page_token"; value: string } | undefined {
const record = readLiveModelCatalogRecord(body);
if (!record || record.has_more === false) {
return undefined;
}
const nextCursor = readLiveModelCatalogString(record.next_cursor);
if (nextCursor) {
return { name: "after", value: nextCursor };
}
const lastId =
readLiveModelCatalogString(record.last_id) ?? readLiveModelCatalogString(record.lastId);
if (lastId) {
return { name: "after_id", value: lastId };
}
const nextPageToken = readLiveModelCatalogString(record.nextPageToken);
if (nextPageToken) {
return { name: "pageToken", value: nextPageToken };
}
const nextPageTokenSnakeCase = readLiveModelCatalogString(record.next_page_token);
return nextPageTokenSnakeCase ? { name: "page_token", value: nextPageTokenSnakeCase } : undefined;
}
type LiveModelCatalogNextPageResolution =
| { status: "complete" }
| { status: "incomplete" }
| { status: "next"; url: string };
function bodyAdvertisesMoreLiveModelCatalogPages(body: unknown): boolean {
const record = readLiveModelCatalogRecord(body);
if (!record || record.has_more === false) {
return false;
}
return Boolean(
record.has_more === true ||
readLiveModelCatalogNextUrl(body) ||
readLiveModelCatalogString(record.next_cursor) ||
readLiveModelCatalogString(record.nextPageToken) ||
readLiveModelCatalogString(record.next_page_token),
);
}
function tryParseUrl(url: string, base?: string): URL | undefined {
try {
return new URL(url, base);
} catch {
return undefined;
}
}
function resolveLiveModelCatalogNextPage(
currentUrl: string,
body: unknown,
): LiveModelCatalogNextPageResolution {
const rawNextUrl = readLiveModelCatalogNextUrl(body);
if (rawNextUrl) {
const currentParsed = tryParseUrl(currentUrl);
const nextUrl = tryParseUrl(rawNextUrl, currentUrl);
if (nextUrl && currentParsed && nextUrl.origin === currentParsed.origin) {
return { status: "next", url: nextUrl.toString() };
}
// The provider advertised a next URL but it is malformed or cross-origin.
// Attempt cursor-based pagination as a fallback before giving up.
const cursor = readLiveModelCatalogCursor(body);
if (cursor) {
const cursorUrl = tryParseUrl(currentUrl);
if (cursorUrl) {
cursorUrl.searchParams.set(cursor.name, cursor.value);
return { status: "next", url: cursorUrl.toString() };
}
}
// No usable fallback: the provider explicitly advertised a next page we
// cannot follow. Return incomplete so the caller surfaces a controlled
// error instead of silently returning a truncated catalog.
return { status: "incomplete" };
}
const cursor = readLiveModelCatalogCursor(body);
if (cursor) {
const nextUrl = tryParseUrl(currentUrl);
if (nextUrl) {
nextUrl.searchParams.set(cursor.name, cursor.value);
return { status: "next", url: nextUrl.toString() };
}
}
return bodyAdvertisesMoreLiveModelCatalogPages(body)
? { status: "incomplete" }
: { status: "complete" };
}
async function fetchLiveProviderModelCatalogPage(
params: FetchLiveProviderModelRowsParams & {
fetchGuard: LiveModelCatalogFetchGuard;
url: string;
timeoutMs: number;
safeReplayHeaders?: Headers;
},
): Promise<{ body: unknown; finalUrl: string; requestHeaders: Headers; rows: readonly unknown[] }> {
const requestHeaders = buildHeaders(params, params.safeReplayHeaders);
const { response, finalUrl, release } = await params.fetchGuard({
url: params.url,
init: {
headers: requestHeaders,
},
signal: params.signal,
timeoutMs: params.timeoutMs,
policy: params.policy ?? ssrfPolicyFromHttpBaseUrlAllowedHostname(params.endpoint),
...(params.lookupFn ? { lookupFn: params.lookupFn } : {}),
...(params.requireHttps !== undefined ? { requireHttps: params.requireHttps } : {}),
auditContext: params.auditContext ?? `${params.providerId}-model-discovery`,
});
try {
if (!response.ok) {
await cancelUnreadResponseBody(response);
throw new LiveModelCatalogHttpError(params.providerId, response.status);
}
const body = await readLiveModelCatalogJson(response, {
label: `${params.providerId} model discovery`,
timeoutMs: params.timeoutMs,
requestHeaders,
});
return {
body,
finalUrl,
requestHeaders,
rows: (params.readRows ?? readDefaultLiveModelCatalogRows)(body),
};
} finally {
await release();
}
}
export async function fetchLiveProviderModelRows(
params: FetchLiveProviderModelRowsParams,
): Promise<readonly unknown[]> {
const fetchGuard = params.fetchGuard ?? fetchWithSsrFGuard;
const timeoutMs = params.timeoutMs ?? 5_000;
const startedAt = Date.now();
const rows: unknown[] = [];
const seenPageUrls = new Set<string>();
let pageUrl: string | undefined = params.endpoint;
let safeReplayHeaders: Headers | undefined;
for (let page = 0; page < LIVE_MODEL_CATALOG_MAX_PAGES && pageUrl; page += 1) {
if (seenPageUrls.has(pageUrl)) {
break;
}
const remainingTimeoutMs = timeoutMs - (Date.now() - startedAt);
if (remainingTimeoutMs <= 0) {
throw new Error(
`${params.providerId} model discovery exceeded ${timeoutMs}ms before the catalog completed`,
);
}
seenPageUrls.add(pageUrl);
const requestedPageUrl = pageUrl;
const result = await fetchLiveProviderModelCatalogPage({
...params,
fetchGuard,
url: requestedPageUrl,
timeoutMs: remainingTimeoutMs,
safeReplayHeaders,
});
rows.push(...result.rows);
const finalParsed = tryParseUrl(result.finalUrl);
const requestedParsed = tryParseUrl(requestedPageUrl);
if (
safeReplayHeaders ||
!finalParsed ||
!requestedParsed ||
finalParsed.origin !== requestedParsed.origin
) {
safeReplayHeaders = new Headers(
retainSafeHeadersForCrossOriginRedirect(result.requestHeaders),
);
}
const nextPage = resolveLiveModelCatalogNextPage(result.finalUrl, result.body);
if (nextPage.status === "incomplete") {
throw new Error(
`${params.providerId} model discovery did not include a supported next page before the catalog completed`,
);
}
pageUrl = nextPage.status === "next" ? nextPage.url : undefined;
}
if (pageUrl) {
throw new Error(
`${params.providerId} model discovery exceeded ${LIVE_MODEL_CATALOG_MAX_PAGES} pages before the catalog completed`,
);
}
return rows;
}
export function liveModelCatalogAuthCacheKey(
params: LiveModelCatalogHeaderContext,
): string | undefined {
return selectLiveModelCatalogRequestApiKey(params);
}
export async function getCachedLiveProviderModelRows(
params: CachedLiveProviderModelRowsParams,
): Promise<readonly unknown[]> {
return await getCachedLiveCatalogValue({
keyParts: params.cacheKeyParts ?? [
params.providerId,
"model-rows",
params.endpoint,
liveModelCatalogAuthCacheKey(params),
],
ttlMs: params.ttlMs,
load: async () => await fetchLiveProviderModelRows(params),
shouldCache: params.shouldCacheRows,
});
}
export async function fetchLiveProviderModelIds(
params: FetchLiveProviderModelIdsParams,
): Promise<string[]> {
const rows = await fetchLiveProviderModelRows(params);
const readModelId = params.readModelId ?? readLiveModelCatalogId;
const seen = new Set<string>();
const modelIds: string[] = [];
for (const row of rows) {
const modelId = readModelId(row);
if (!modelId || seen.has(modelId)) {
continue;
}
seen.add(modelId);
modelIds.push(modelId);
}
return modelIds;
}

View file

@ -0,0 +1,35 @@
import type { ProviderCatalogOutcome, ProviderCatalogResult } from "../plugins/types.js";
export class LiveModelCatalogHttpError extends Error {
readonly status: number;
constructor(providerId: string, status: number) {
super(`${providerId} model discovery failed: HTTP ${status}`);
this.name = "LiveModelCatalogHttpError";
this.status = status;
}
}
export async function runLiveProviderCatalog(params: {
providerId: string;
profileId?: string;
run: () => Promise<ProviderCatalogResult>;
}): Promise<ProviderCatalogResult> {
const identity = {
provider: params.providerId,
...(params.profileId ? { profileId: params.profileId } : {}),
};
try {
const result = await params.run();
return result
? { ...result, outcomes: [...(result.outcomes ?? []), { ...identity, status: "ready" }] }
: result;
} catch (error) {
const rejected =
error instanceof LiveModelCatalogHttpError && (error.status === 401 || error.status === 403);
const outcome: ProviderCatalogOutcome = rejected
? { ...identity, status: "auth-rejected", rejectionScope: "catalog" }
: { ...identity, status: "unavailable" };
return { providers: {}, outcomes: [outcome] };
}
}

View file

@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ModelProviderConfig } from "../config/types.models.js";
import {
buildLiveModelProviderConfig,
buildOpenAICompatibleLiveModelProviderConfig,
buildOpenAICompatibleProviderFamilyCatalog,
clearLiveCatalogCacheForTests,
type LiveModelCatalogFetchGuard,
} from "./provider-catalog-live-runtime.js";
const { fetchGuard } = vi.hoisted(() => ({ fetchGuard: vi.fn<LiveModelCatalogFetchGuard>() }));
vi.mock("./ssrf-runtime.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./ssrf-runtime.js")>()),
fetchWithSsrFGuard: fetchGuard,
}));
const seed: ModelProviderConfig = {
baseUrl: "https://catalog.example/v1",
api: "openai-completions",
models: [
{
id: "known",
name: "Known",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8_192,
},
],
};
afterEach(() => {
clearLiveCatalogCacheForTests();
fetchGuard.mockReset();
});
describe("strict catalog acquisition", () => {
it.each(["ids", "projection", "openai-compatible"] as const)(
"%s preserves failure, authoritative empty and recovery without seeds",
async (projection) => {
const release = vi.fn(async () => {});
const failure = new Error("catalog transport unavailable");
fetchGuard
.mockRejectedValueOnce(failure)
.mockResolvedValueOnce({
response: Response.json({ data: [] }),
finalUrl: `${seed.baseUrl}/models`,
release,
})
.mockResolvedValueOnce({
response: Response.json({ data: [{ id: "known" }] }),
finalUrl: `${seed.baseUrl}/models`,
release,
});
const params = {
discoveryMode: "strict" as const,
providerId: "demo",
providerConfig: seed,
apiKey: "synthetic-key",
fetchGuard,
};
const acquire = () =>
projection === "openai-compatible"
? buildOpenAICompatibleLiveModelProviderConfig(params)
: buildLiveModelProviderConfig({
...params,
endpoint: `${seed.baseUrl}/models`,
models: seed.models,
...(projection === "projection"
? { projectRows: (rows: readonly unknown[]) => (rows.length ? seed.models : []) }
: {}),
});
await expect(acquire()).rejects.toBe(failure);
await expect(acquire()).resolves.toMatchObject({ models: [] });
await expect(acquire()).resolves.toMatchObject({ models: seed.models });
expect(release).toHaveBeenCalledTimes(2);
},
);
it.each([401, 503])(
"HTTP %s preserves a healthy family sibling and captured auth",
async (status) => {
const release = vi.fn(async () => {});
fetchGuard.mockImplementation(async ({ url, init }) => {
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer resolved-family-key");
return {
response: url.includes("unavailable")
? Response.json({}, { status })
: Response.json({ data: [{ id: "known" }] }),
finalUrl: url,
release,
};
});
const family = buildOpenAICompatibleProviderFamilyCatalog({
discoveryMode: "strict",
credentialProviderId: "family",
entries: ["unavailable", "healthy"].map((id) => ({
id,
label: id,
baseUrl: `https://${id}.example/v1`,
models: seed.models,
buildProvider: () => ({ ...seed, baseUrl: `https://${id}.example/v1` }),
})),
staticCatalog: async () => ({ providers: {} }),
augmentModelCatalog: () => [],
});
const resolveProviderApiKey = vi.fn(() => ({
apiKey: "family:profile",
discoveryApiKey: "resolved-family-key",
profileId: "family:profile",
}));
const resolveProviderAuth = vi.fn(() => {
throw new Error("Do not reselect auth");
});
await expect(
family.catalog.run({ config: {}, env: {}, resolveProviderApiKey, resolveProviderAuth }),
).resolves.toMatchObject({
providers: { healthy: { models: seed.models } },
outcomes: [
{
provider: "unavailable",
profileId: "family:profile",
status: status === 401 ? "auth-rejected" : "unavailable",
...(status === 401 ? { rejectionScope: "catalog" } : {}),
},
{ provider: "healthy", profileId: "family:profile", status: "ready" },
],
});
expect(resolveProviderApiKey).toHaveBeenCalledOnce();
expect(resolveProviderAuth).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledTimes(2);
},
);
});

View file

@ -1,54 +1,54 @@
import { normalizeOptionalString as readLiveModelCatalogString } from "../../packages/normalization-core/src/string-coerce.js";
import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js";
import { readProviderJsonResponse } from "../agents/provider-http-errors.js";
import { cancelUnreadResponseBody } from "../infra/http-body.js";
import { retainSafeHeadersForCrossOriginRedirect } from "../infra/net/redirect-headers.js";
import type {
ProviderCatalogContext,
ProviderCatalogResult,
ProviderPlugin,
} from "../plugins/types.js";
import {
buildOpenAICompatibleLiveModels,
isUpstreamProviderCatalogModel,
readLiveModelCatalogBooleanField,
readLiveModelCatalogId,
readLiveModelCatalogPositiveSafeIntegerField,
readLiveModelCatalogRecord,
readLiveModelCatalogStringField,
type UpstreamProviderCatalog,
type UpstreamProviderCatalogModel,
} from "./provider-catalog-live-normalize.internal.js";
fetchLiveProviderModelIds,
getCachedLiveProviderModelRows,
liveModelCatalogAuthCacheKey,
type FetchLiveProviderModelIdsParams,
type FetchLiveProviderModelRowsParams,
type LiveModelCatalogFetchGuard,
type LiveModelRowProjection,
} from "./provider-catalog-live-acquisition.internal.js";
import { buildOpenAICompatibleLiveModels } from "./provider-catalog-live-normalize.internal.js";
import {
LiveModelCatalogHttpError,
runLiveProviderCatalog,
} from "./provider-catalog-live-outcome.internal.js";
import {
buildSingleProviderApiKeyCatalog,
getCachedLiveCatalogValue,
type ManifestProviderCatalogEntry,
} from "./provider-catalog-shared.js";
import type { ManifestProviderCatalogEntry } from "./provider-catalog-shared.js";
import {
normalizeProviderId,
type ModelDefinitionConfig,
type ModelProviderConfig,
} from "./provider-model-shared.js";
import {
fetchWithSsrFGuard,
type LookupFn,
ssrfPolicyFromHttpBaseUrlAllowedHostname,
type SsrFPolicy,
} from "./ssrf-runtime.js";
export type LiveModelCatalogFetchGuard = typeof fetchWithSsrFGuard;
export type LiveModelCatalogHeaderContext = {
apiKey?: string;
discoveryApiKey?: string;
};
export { LiveModelCatalogHttpError, runLiveProviderCatalog };
export { fetchLiveProviderModelIds, getCachedLiveProviderModelRows };
export {
fetchLiveProviderModelRows,
getCachedUpstreamProviderCatalog,
} from "./provider-catalog-live-acquisition.internal.js";
export type {
CachedLiveProviderModelRowsParams,
FetchLiveProviderModelIdsParams,
FetchLiveProviderModelRowsParams,
GetCachedUpstreamProviderCatalogParams,
LiveModelCatalogFetchGuard,
LiveModelCatalogHeaderContext,
LiveModelRowProjection,
} from "./provider-catalog-live-acquisition.internal.js";
export { clearLiveCatalogCacheForTests } from "./provider-catalog-shared.js";
export {
readLiveModelCatalogBooleanField,
readLiveModelCatalogPositiveSafeIntegerField,
readLiveModelCatalogStringField,
};
} from "./provider-catalog-live-normalize.internal.js";
export {
listProviderCatalogSnapshotEntries,
projectProviderCatalogSnapshotRows,
@ -61,70 +61,9 @@ export type {
UpstreamProviderCatalogModel,
} from "./provider-catalog-live-normalize.internal.js";
export type FetchLiveProviderModelIdsParams = {
providerId: string;
endpoint: string;
apiKey?: string;
discoveryApiKey?: string;
fetchGuard?: LiveModelCatalogFetchGuard;
signal?: AbortSignal;
timeoutMs?: number;
auditContext?: string;
policy?: SsrFPolicy;
lookupFn?: LookupFn;
requireHttps?: boolean;
readRows?: (body: unknown) => readonly unknown[];
readModelId?: (row: unknown) => string | undefined;
buildRequestHeaders?: (ctx: LiveModelCatalogHeaderContext) => HeadersInit;
};
export type FetchLiveProviderModelRowsParams = Omit<FetchLiveProviderModelIdsParams, "readModelId">;
export type CachedLiveProviderModelRowsParams = FetchLiveProviderModelRowsParams & {
ttlMs?: number;
cacheKeyParts?: readonly unknown[];
shouldCacheRows?: (rows: readonly unknown[]) => boolean;
};
export type GetCachedUpstreamProviderCatalogParams = {
endpoint: string;
providerId: string;
fetchGuard?: LiveModelCatalogFetchGuard;
signal?: AbortSignal;
timeoutMs?: number;
ttlMs?: number;
};
export type LiveModelRowProjection<T extends ModelDefinitionConfig = ModelDefinitionConfig> = (
rows: readonly unknown[],
fallback: ModelProviderConfig,
) => readonly T[];
// Live model catalogs are fetched at runtime from provider-controlled endpoints,
// so the success body is untrusted just like the error body. A faulty or hostile
// provider can stream an unbounded JSON document; reading it without a ceiling
// lets a single discovery call exhaust process memory. The cap is sized well
// above the largest known catalog (OpenRouter's live catalog is already >100KB
// and grows) while still bounding memory, matching the existing bounded reads
// for provider error bodies.
const LIVE_MODEL_CATALOG_BODY_MAX_BYTES = 4 * 1024 * 1024;
// Shared upstream feeds cover many providers and already exceed the ordinary
// single-provider ceiling; bound this explicitly without weakening that limit.
const UPSTREAM_PROVIDER_CATALOG_BODY_MAX_BYTES = 8 * 1024 * 1024;
const LIVE_MODEL_CATALOG_MAX_PAGES = 50;
export class LiveModelCatalogHttpError extends Error {
readonly status: number;
constructor(providerId: string, status: number) {
super(`${providerId} model discovery failed: HTTP ${status}`);
this.name = "LiveModelCatalogHttpError";
this.status = status;
}
}
export type BuildLiveModelProviderConfigParams<T extends ModelDefinitionConfig> =
FetchLiveProviderModelIdsParams & {
discoveryMode?: "strict";
providerConfig: Omit<ModelProviderConfig, "models">;
models: readonly T[];
ttlMs?: number;
@ -136,6 +75,7 @@ export type BuildLiveModelProviderConfigParams<T extends ModelDefinitionConfig>
};
export type OpenAICompatibleModelDiscoveryOptions = {
authentication?: "none";
/** Fixed endpoint used only while the effective inference base remains canonical. */
endpointUrl?: {
url: string;
@ -169,6 +109,7 @@ export type BuildOpenAICompatibleProviderCatalogParams = {
buildProvider: () => ModelProviderConfig | Promise<ModelProviderConfig>;
allowExplicitBaseUrl?: boolean;
modelDiscovery?: OpenAICompatibleModelDiscoveryOptions;
discoveryMode?: "strict";
};
function matchesProviderCatalogScope(
@ -181,379 +122,6 @@ function matchesProviderCatalogScope(
);
}
function readDefaultLiveModelCatalogRows(body: unknown): readonly unknown[] {
if (Array.isArray(body)) {
return body;
}
if (body && typeof body === "object" && Array.isArray((body as { data?: unknown }).data)) {
return (body as { data: unknown[] }).data;
}
throw new Error("Live model catalog response must be an array or { data: [] }");
}
function normalizeLiveModelCatalogRequestApiKey(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed || isNonSecretApiKeyMarker(trimmed)) {
return undefined;
}
return trimmed;
}
function selectLiveModelCatalogRequestApiKey(
ctx: LiveModelCatalogHeaderContext,
): string | undefined {
return (
// Explicit discovery credentials are resolved bytes; only apiKey can be a placeholder.
readLiveModelCatalogString(ctx.discoveryApiKey) ??
normalizeLiveModelCatalogRequestApiKey(ctx.apiKey)
);
}
function buildDefaultLiveModelCatalogHeaders(ctx: LiveModelCatalogHeaderContext): HeadersInit {
const requestApiKey = selectLiveModelCatalogRequestApiKey(ctx);
return {
Accept: "application/json",
...(requestApiKey ? { Authorization: `Bearer ${requestApiKey}` } : {}),
};
}
function buildHeaders(
params: FetchLiveProviderModelIdsParams,
safeReplayHeaders?: Headers,
): Headers {
const headers = safeReplayHeaders
? new Headers(safeReplayHeaders)
: new Headers(
(params.buildRequestHeaders ?? buildDefaultLiveModelCatalogHeaders)({
apiKey: normalizeLiveModelCatalogRequestApiKey(params.apiKey),
discoveryApiKey: selectLiveModelCatalogRequestApiKey(params),
}),
);
if (!headers.has("accept")) {
headers.set("accept", "application/json");
}
return headers;
}
async function readLiveModelCatalogJson(
response: Response,
params: { label: string; timeoutMs: number; maxBytes?: number; requestHeaders?: HeadersInit },
): Promise<unknown> {
return await readProviderJsonResponse(response, params.label, {
chunkTimeoutMs: params.timeoutMs,
maxBytes: params.maxBytes ?? LIVE_MODEL_CATALOG_BODY_MAX_BYTES,
requestHeaders: params.requestHeaders,
onOverflow: ({ size, maxBytes }) =>
new Error(`Live model catalog response exceeded ${maxBytes} bytes (${size} bytes received)`),
onIdleTimeout: ({ chunkTimeoutMs }) =>
new Error(`Live model catalog response stalled: no data received for ${chunkTimeoutMs}ms`),
});
}
/** Loads one provider from a shared public metadata feed only when explicitly requested. */
export async function getCachedUpstreamProviderCatalog(
params: GetCachedUpstreamProviderCatalogParams,
): Promise<UpstreamProviderCatalog | undefined> {
const body = await getCachedLiveCatalogValue({
// Provider ids intentionally stay out of this key: sibling providers share
// one upstream document and must not download it once per provider.
keyParts: ["upstream-provider-catalog", params.endpoint],
ttlMs: params.ttlMs ?? 300_000,
load: async () => {
const timeoutMs = params.timeoutMs ?? 15_000;
const { response, release } = await (params.fetchGuard ?? fetchWithSsrFGuard)({
url: params.endpoint,
init: { headers: { Accept: "application/json" } },
signal: params.signal,
timeoutMs,
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(params.endpoint),
requireHttps: true,
auditContext: "upstream-provider-catalog-discovery",
});
try {
if (!response.ok) {
await cancelUnreadResponseBody(response);
throw new LiveModelCatalogHttpError("upstream-provider-catalog", response.status);
}
const catalog = readLiveModelCatalogRecord(
await readLiveModelCatalogJson(response, {
label: "upstream-provider-catalog",
timeoutMs,
maxBytes: UPSTREAM_PROVIDER_CATALOG_BODY_MAX_BYTES,
}),
);
if (!catalog) {
throw new Error("Upstream provider catalog response must be an object");
}
return catalog;
} finally {
await release();
}
},
});
const provider = readLiveModelCatalogRecord(body[params.providerId]);
const models = readLiveModelCatalogRecord(provider?.models);
if (
!provider ||
!models ||
readLiveModelCatalogStringField(provider, "id") !== params.providerId
) {
return undefined;
}
return {
id: params.providerId,
...(readLiveModelCatalogStringField(provider, "api")
? { api: readLiveModelCatalogStringField(provider, "api") }
: {}),
...(readLiveModelCatalogStringField(provider, "npm")
? { npm: readLiveModelCatalogStringField(provider, "npm") }
: {}),
models: Object.fromEntries(
Object.entries(models).filter((entry): entry is [string, UpstreamProviderCatalogModel] =>
isUpstreamProviderCatalogModel(entry[1]),
),
),
};
}
function readLiveModelCatalogNextUrl(body: unknown): string | undefined {
const record = readLiveModelCatalogRecord(body);
if (!record) {
return undefined;
}
const links = readLiveModelCatalogRecord(record.links);
return readLiveModelCatalogString(record.next) ?? readLiveModelCatalogString(links?.next);
}
function readLiveModelCatalogCursor(
body: unknown,
): { name: "after" | "after_id" | "pageToken" | "page_token"; value: string } | undefined {
const record = readLiveModelCatalogRecord(body);
if (!record || record.has_more === false) {
return undefined;
}
const nextCursor = readLiveModelCatalogString(record.next_cursor);
if (nextCursor) {
return { name: "after", value: nextCursor };
}
const lastId =
readLiveModelCatalogString(record.last_id) ?? readLiveModelCatalogString(record.lastId);
if (lastId) {
return { name: "after_id", value: lastId };
}
const nextPageToken = readLiveModelCatalogString(record.nextPageToken);
if (nextPageToken) {
return { name: "pageToken", value: nextPageToken };
}
const nextPageTokenSnakeCase = readLiveModelCatalogString(record.next_page_token);
return nextPageTokenSnakeCase ? { name: "page_token", value: nextPageTokenSnakeCase } : undefined;
}
type LiveModelCatalogNextPageResolution =
| { status: "complete" }
| { status: "incomplete" }
| { status: "next"; url: string };
function bodyAdvertisesMoreLiveModelCatalogPages(body: unknown): boolean {
const record = readLiveModelCatalogRecord(body);
if (!record || record.has_more === false) {
return false;
}
return Boolean(
record.has_more === true ||
readLiveModelCatalogNextUrl(body) ||
readLiveModelCatalogString(record.next_cursor) ||
readLiveModelCatalogString(record.nextPageToken) ||
readLiveModelCatalogString(record.next_page_token),
);
}
function tryParseUrl(url: string, base?: string): URL | undefined {
try {
return new URL(url, base);
} catch {
return undefined;
}
}
function resolveLiveModelCatalogNextPage(
currentUrl: string,
body: unknown,
): LiveModelCatalogNextPageResolution {
const rawNextUrl = readLiveModelCatalogNextUrl(body);
if (rawNextUrl) {
const currentParsed = tryParseUrl(currentUrl);
const nextUrl = tryParseUrl(rawNextUrl, currentUrl);
if (nextUrl && currentParsed && nextUrl.origin === currentParsed.origin) {
return { status: "next", url: nextUrl.toString() };
}
// The provider advertised a next URL but it is malformed or cross-origin.
// Attempt cursor-based pagination as a fallback before giving up.
const cursor = readLiveModelCatalogCursor(body);
if (cursor) {
const cursorUrl = tryParseUrl(currentUrl);
if (cursorUrl) {
cursorUrl.searchParams.set(cursor.name, cursor.value);
return { status: "next", url: cursorUrl.toString() };
}
}
// No usable fallback: the provider explicitly advertised a next page we
// cannot follow. Return incomplete so the caller surfaces a controlled
// error instead of silently returning a truncated catalog.
return { status: "incomplete" };
}
const cursor = readLiveModelCatalogCursor(body);
if (cursor) {
const nextUrl = tryParseUrl(currentUrl);
if (nextUrl) {
nextUrl.searchParams.set(cursor.name, cursor.value);
return { status: "next", url: nextUrl.toString() };
}
}
return bodyAdvertisesMoreLiveModelCatalogPages(body)
? { status: "incomplete" }
: { status: "complete" };
}
async function fetchLiveProviderModelCatalogPage(
params: FetchLiveProviderModelRowsParams & {
fetchGuard: LiveModelCatalogFetchGuard;
url: string;
timeoutMs: number;
safeReplayHeaders?: Headers;
},
): Promise<{ body: unknown; finalUrl: string; requestHeaders: Headers; rows: readonly unknown[] }> {
const requestHeaders = buildHeaders(params, params.safeReplayHeaders);
const { response, finalUrl, release } = await params.fetchGuard({
url: params.url,
init: {
headers: requestHeaders,
},
signal: params.signal,
timeoutMs: params.timeoutMs,
policy: params.policy ?? ssrfPolicyFromHttpBaseUrlAllowedHostname(params.endpoint),
...(params.lookupFn ? { lookupFn: params.lookupFn } : {}),
...(params.requireHttps !== undefined ? { requireHttps: params.requireHttps } : {}),
auditContext: params.auditContext ?? `${params.providerId}-model-discovery`,
});
try {
if (!response.ok) {
await cancelUnreadResponseBody(response);
throw new LiveModelCatalogHttpError(params.providerId, response.status);
}
const body = await readLiveModelCatalogJson(response, {
label: `${params.providerId} model discovery`,
timeoutMs: params.timeoutMs,
requestHeaders,
});
return {
body,
finalUrl,
requestHeaders,
rows: (params.readRows ?? readDefaultLiveModelCatalogRows)(body),
};
} finally {
await release();
}
}
export async function fetchLiveProviderModelRows(
params: FetchLiveProviderModelRowsParams,
): Promise<readonly unknown[]> {
const fetchGuard = params.fetchGuard ?? fetchWithSsrFGuard;
const timeoutMs = params.timeoutMs ?? 5_000;
const startedAt = Date.now();
const rows: unknown[] = [];
const seenPageUrls = new Set<string>();
let pageUrl: string | undefined = params.endpoint;
let safeReplayHeaders: Headers | undefined;
for (let page = 0; page < LIVE_MODEL_CATALOG_MAX_PAGES && pageUrl; page += 1) {
if (seenPageUrls.has(pageUrl)) {
break;
}
const remainingTimeoutMs = timeoutMs - (Date.now() - startedAt);
if (remainingTimeoutMs <= 0) {
throw new Error(
`${params.providerId} model discovery exceeded ${timeoutMs}ms before the catalog completed`,
);
}
seenPageUrls.add(pageUrl);
const requestedPageUrl = pageUrl;
const result = await fetchLiveProviderModelCatalogPage({
...params,
fetchGuard,
url: requestedPageUrl,
timeoutMs: remainingTimeoutMs,
safeReplayHeaders,
});
rows.push(...result.rows);
const finalParsed = tryParseUrl(result.finalUrl);
const requestedParsed = tryParseUrl(requestedPageUrl);
if (
safeReplayHeaders ||
!finalParsed ||
!requestedParsed ||
finalParsed.origin !== requestedParsed.origin
) {
safeReplayHeaders = new Headers(
retainSafeHeadersForCrossOriginRedirect(result.requestHeaders),
);
}
const nextPage = resolveLiveModelCatalogNextPage(result.finalUrl, result.body);
if (nextPage.status === "incomplete") {
throw new Error(
`${params.providerId} model discovery did not include a supported next page before the catalog completed`,
);
}
pageUrl = nextPage.status === "next" ? nextPage.url : undefined;
}
if (pageUrl) {
throw new Error(
`${params.providerId} model discovery exceeded ${LIVE_MODEL_CATALOG_MAX_PAGES} pages before the catalog completed`,
);
}
return rows;
}
function liveModelCatalogAuthCacheKey(params: LiveModelCatalogHeaderContext): string | undefined {
return selectLiveModelCatalogRequestApiKey(params);
}
export async function getCachedLiveProviderModelRows(
params: CachedLiveProviderModelRowsParams,
): Promise<readonly unknown[]> {
return await getCachedLiveCatalogValue({
keyParts: params.cacheKeyParts ?? [
params.providerId,
"model-rows",
params.endpoint,
liveModelCatalogAuthCacheKey(params),
],
ttlMs: params.ttlMs,
load: async () => await fetchLiveProviderModelRows(params),
shouldCache: params.shouldCacheRows,
});
}
export async function fetchLiveProviderModelIds(
params: FetchLiveProviderModelIdsParams,
): Promise<string[]> {
const rows = await fetchLiveProviderModelRows(params);
const readModelId = params.readModelId ?? readLiveModelCatalogId;
const seen = new Set<string>();
const modelIds: string[] = [];
for (const row of rows) {
const modelId = readModelId(row);
if (!modelId || seen.has(modelId)) {
continue;
}
seen.add(modelId);
modelIds.push(modelId);
}
return modelIds;
}
function buildProviderConfig<T extends ModelDefinitionConfig>(
params: BuildLiveModelProviderConfigParams<T>,
models: readonly T[],
@ -591,6 +159,7 @@ async function projectCachedLiveModelRows<T extends ModelDefinitionConfig>(
} catch (error) {
if (
params.fallbackToAnonymousOnUnauthorized &&
params.discoveryMode !== "strict" &&
error instanceof LiveModelCatalogHttpError &&
error.status === 401 &&
(params.apiKey || params.discoveryApiKey)
@ -612,7 +181,7 @@ export async function buildLiveModelProviderConfig<T extends ModelDefinitionConf
fallback,
projectRows: params.projectRows,
});
if (models.length > 0) {
if (models.length > 0 || params.discoveryMode === "strict") {
return { ...fallback, models: [...models] };
}
return fallback;
@ -630,10 +199,13 @@ export async function buildLiveModelProviderConfig<T extends ModelDefinitionConf
});
const liveModelIdSet = new Set(liveModelIds);
const models = params.models.filter((model) => liveModelIdSet.has(model.id));
if (models.length > 0) {
if (models.length > 0 || params.discoveryMode === "strict") {
return buildProviderConfig(params, models);
}
} catch {
} catch (error) {
if (params.discoveryMode === "strict") {
throw error;
}
// Live model catalogs are advisory. Keep provider-owned static rows visible
// when discovery is unavailable or the provider returns an unexpected body.
}
@ -655,16 +227,21 @@ function resolveFixedLiveModelDiscoveryEndpoint(
return effectiveBaseUrl === requiredBaseUrl ? endpoint.url : undefined;
}
export async function buildOpenAICompatibleLiveModelProviderConfig(params: {
type OpenAICompatibleLiveModelProviderParams = {
providerId: string;
providerConfig: ModelProviderConfig;
apiKey?: string;
discoveryApiKey?: string;
profileId?: string;
modelDiscovery?: OpenAICompatibleModelDiscoveryOptions;
fetchGuard?: LiveModelCatalogFetchGuard;
signal?: AbortSignal;
}): Promise<ModelProviderConfig> {
const { models, ...providerConfig } = params.providerConfig;
discoveryMode?: "strict";
};
function prepareOpenAICompatibleLiveModelDiscovery(
params: OpenAICompatibleLiveModelProviderParams,
) {
const fallback = {
...params.providerConfig,
...(params.apiKey ? { apiKey: params.apiKey } : {}),
@ -677,15 +254,25 @@ export async function buildOpenAICompatibleLiveModelProviderConfig(params: {
params.modelDiscovery?.endpointPath ?? "models",
);
if (!endpoint) {
return fallback;
return { kind: "static" as const, provider: fallback };
}
return await buildLiveModelProviderConfig({
const { models, ...providerConfig } = fallback;
const auth =
params.modelDiscovery?.authentication === "none"
? {}
: {
apiKey: params.apiKey,
discoveryApiKey: params.discoveryApiKey,
profileId: params.profileId,
};
const request: BuildLiveModelProviderConfigParams<ModelDefinitionConfig> = {
discoveryMode: params.discoveryMode,
providerId: params.providerId,
endpoint,
providerConfig,
models,
apiKey: params.apiKey,
discoveryApiKey: params.discoveryApiKey,
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
fetchGuard: params.fetchGuard,
signal: params.signal,
timeoutMs: params.modelDiscovery?.timeoutMs,
@ -697,7 +284,36 @@ export async function buildOpenAICompatibleLiveModelProviderConfig(params: {
params.modelDiscovery?.projectRows ??
((rows, fallbackProvider) =>
buildOpenAICompatibleLiveModels(rows, fallbackProvider, acceptUnknownModel)),
};
return { kind: "live" as const, request, profileId: auth.profileId };
}
export async function buildOpenAICompatibleLiveModelProviderConfig(
params: OpenAICompatibleLiveModelProviderParams,
): Promise<ModelProviderConfig> {
const discovery = prepareOpenAICompatibleLiveModelDiscovery(params);
return discovery.kind === "static"
? discovery.provider
: await buildLiveModelProviderConfig(discovery.request);
}
export async function buildOpenAICompatibleLiveProviderCatalog(
params: OpenAICompatibleLiveModelProviderParams,
): Promise<ProviderCatalogResult> {
const discovery = prepareOpenAICompatibleLiveModelDiscovery(params);
if (discovery.kind === "static") {
return { provider: discovery.provider };
}
const run = async () => ({
provider: await buildLiveModelProviderConfig(discovery.request),
});
return params.discoveryMode === "strict"
? await runLiveProviderCatalog({
providerId: params.providerId,
profileId: discovery.profileId,
run,
})
: await run();
}
/** Builds the shared authenticated live/static hooks for an ordered provider family. */
@ -706,6 +322,7 @@ export function buildOpenAICompatibleProviderFamilyCatalog(params: {
entries: readonly ManifestProviderCatalogEntry[];
staticCatalog: () => Promise<{ providers: Record<string, ModelProviderConfig> }>;
augmentModelCatalog: NonNullable<ProviderPlugin["augmentModelCatalog"]>;
discoveryMode?: "strict";
}) {
return {
catalog: {
@ -719,23 +336,28 @@ export function buildOpenAICompatibleProviderFamilyCatalog(params: {
if (!auth.apiKey) {
return null;
}
const results = await Promise.all(
entries.map(async ({ id, buildProvider }) => ({
id,
result: await buildOpenAICompatibleLiveProviderCatalog({
providerId: id,
providerConfig: buildProvider(),
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
profileId: auth.profileId,
discoveryMode: params.discoveryMode,
}),
})),
);
return {
providers: Object.fromEntries(
await Promise.all(
entries.map(
async ({ id, buildProvider }) =>
[
id,
await buildOpenAICompatibleLiveModelProviderConfig({
providerId: id,
providerConfig: buildProvider(),
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
}),
] as const,
),
results.flatMap(({ id, result }) =>
result && "provider" in result ? [[id, result.provider]] : [],
),
),
...(params.discoveryMode === "strict"
? { outcomes: results.flatMap(({ result }) => result?.outcomes ?? []) }
: {}),
};
},
staticRun: params.staticCatalog,
@ -752,8 +374,9 @@ export async function buildOpenAICompatibleProviderCatalog(
) {
return null;
}
const auth = params.ctx.resolveProviderApiKey(normalizeProviderId(params.providerId));
const result = await buildSingleProviderApiKeyCatalog({
ctx: params.ctx,
ctx: { ...params.ctx, resolveProviderApiKey: () => auth },
providerId: params.providerId,
buildProvider: params.buildProvider,
allowExplicitBaseUrl: params.allowExplicitBaseUrl,
@ -761,14 +384,13 @@ export async function buildOpenAICompatibleProviderCatalog(
if (!result || !("provider" in result)) {
return result;
}
const auth = params.ctx.resolveProviderApiKey(params.providerId);
return {
provider: await buildOpenAICompatibleLiveModelProviderConfig({
providerId: params.providerId,
providerConfig: result.provider,
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
modelDiscovery: params.modelDiscovery,
}),
};
return await buildOpenAICompatibleLiveProviderCatalog({
providerId: params.providerId,
providerConfig: result.provider,
apiKey: auth.apiKey,
discoveryApiKey: auth.discoveryApiKey,
modelDiscovery: params.modelDiscovery,
profileId: auth.profileId,
discoveryMode: params.discoveryMode,
});
}

View file

@ -40,10 +40,17 @@ import type {
import type { OpenAICompatibleModelDiscoveryOptions } from "./provider-catalog-live-runtime.js";
// Registration needs static metadata; live discovery loads only when its catalog hook runs.
const liveCatalogRuntime = createLazyRuntimeModule(
() => import("./provider-catalog-live-runtime.js"),
);
const buildOpenAICompatibleProviderCatalog = createLazyRuntimeMethod(
createLazyRuntimeModule(() => import("./provider-catalog-live-runtime.js")),
liveCatalogRuntime,
(runtime) => runtime.buildOpenAICompatibleProviderCatalog,
);
const runLiveProviderCatalog = createLazyRuntimeMethod(
liveCatalogRuntime,
(runtime) => runtime.runLiveProviderCatalog,
);
// Auth descriptors are safe to construct before the lazy credential runtime is needed.
export { createProviderApiKeyAuthMethod };
@ -127,6 +134,7 @@ export type SingleProviderPluginCatalogOptions =
* Discovers text/chat models from the provider's OpenAI-compatible model-list endpoint.
*/
liveModelDiscovery?: true | OpenAICompatibleModelDiscoveryOptions;
discoveryMode?: "strict";
run?: never;
order?: never;
staticRun?: never;
@ -148,6 +156,7 @@ export type SingleProviderPluginCatalogOptions =
buildStaticProvider?: never;
allowExplicitBaseUrl?: never;
liveModelDiscovery?: never;
discoveryMode?: never;
};
/**
@ -457,27 +466,29 @@ export function defineSingleProviderPluginEntry(options: SingleProviderPluginOpt
) {
return Promise.resolve(null);
}
return provider.catalog.liveModelDiscovery
? buildOpenAICompatibleProviderCatalog({
ctx,
providerId,
providerAliases: [...(provider.aliases ?? []), ...(provider.hookAliases ?? [])],
buildProvider,
...(provider.catalog.allowExplicitBaseUrl
? { allowExplicitBaseUrl: true }
: {}),
...(provider.catalog.liveModelDiscovery === true
? {}
: { modelDiscovery: provider.catalog.liveModelDiscovery }),
})
: buildSingleProviderApiKeyCatalog({
ctx,
providerId,
buildProvider,
...(provider.catalog.allowExplicitBaseUrl
? { allowExplicitBaseUrl: true }
: {}),
});
if (provider.catalog.liveModelDiscovery) {
return buildOpenAICompatibleProviderCatalog({
ctx,
providerId,
providerAliases: [...(provider.aliases ?? []), ...(provider.hookAliases ?? [])],
buildProvider,
discoveryMode: provider.catalog.discoveryMode,
...(provider.catalog.allowExplicitBaseUrl ? { allowExplicitBaseUrl: true } : {}),
...(provider.catalog.liveModelDiscovery === true
? {}
: { modelDiscovery: provider.catalog.liveModelDiscovery }),
});
}
const run = () =>
buildSingleProviderApiKeyCatalog({
ctx,
providerId,
buildProvider,
...(provider.catalog.allowExplicitBaseUrl ? { allowExplicitBaseUrl: true } : {}),
});
return provider.catalog.discoveryMode === "strict"
? runLiveProviderCatalog({ providerId, run })
: run();
},
};
}

View file

@ -717,6 +717,17 @@ export function describeMinimaxProviderDiscoveryContract(
describe("minimax provider discovery contract", () => {
installDiscoveryHooks(state, { providerIds: ["minimax"], loadMinimax: load });
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Response.json({
data: [{ id: "MiniMax-M3" }, { id: "MiniMax-M2.7" }, { id: "MiniMax-M2.7-highspeed" }],
}),
),
);
});
afterEach(() => vi.unstubAllGlobals());
it("keeps API catalog provider-owned", async () => {
const result = await state.runProviderCatalog({
@ -819,6 +830,17 @@ export function describeModelStudioProviderDiscoveryContract(
describe("modelstudio provider discovery contract", () => {
installDiscoveryHooks(state, { providerIds: ["modelstudio"], loadModelStudio: load });
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Response.json({
data: [{ id: "qwen3.5-plus" }, { id: "qwen3-max-2026-01-23" }, { id: "MiniMax-M2.5" }],
}),
),
);
});
afterEach(() => vi.unstubAllGlobals());
it("keeps catalog provider-owned", async () => {
const result = await state.runProviderCatalog({

View file

@ -21,6 +21,7 @@ export type ProviderCatalogContext = {
resolveProviderApiKey: (providerId?: string) => {
apiKey: string | undefined;
discoveryApiKey?: string;
profileId?: string;
};
resolveProviderAuth: (
providerId?: string,

View file

@ -8,7 +8,7 @@ import {
copyProviderCatalogOutcomes,
copyProviderCatalogResultProjection,
} from "./provider-catalog-result.js";
import type { ProviderCatalogOutcome } from "./provider-catalog.types.js";
import type { ProviderCatalogContext, ProviderCatalogOutcome } from "./provider-catalog.types.js";
import type { ProviderCatalogOrder, ProviderPlugin } from "./types.js";
const DISCOVERY_ORDER: readonly ProviderCatalogOrder[] = ["simple", "profile", "paired", "late"];
@ -43,7 +43,7 @@ type PreparedProviderStaticCatalogEntry = Readonly<{
}>;
export type PreparedProviderStaticCatalog = Readonly<{
/** Discovery-entry providers captured for this config/workspace generation. */
/** Provider handles captured for this config/workspace generation. */
providers?: readonly ProviderPlugin[];
entries: readonly PreparedProviderStaticCatalogEntry[];
}>;
@ -162,20 +162,8 @@ export async function runProviderCatalog(params: {
agentDir?: string;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
resolveProviderApiKey: (providerId?: string) => {
apiKey: string | undefined;
discoveryApiKey?: string;
};
resolveProviderAuth: (
providerId?: string,
options?: { oauthMarker?: string },
) => {
apiKey: string | undefined;
discoveryApiKey?: string;
mode: "api_key" | "aws-sdk" | "oauth" | "token" | "none";
source: "env" | "profile" | "none";
profileId?: string;
};
resolveProviderApiKey: ProviderCatalogContext["resolveProviderApiKey"];
resolveProviderAuth: ProviderCatalogContext["resolveProviderAuth"];
reportCatalogOutcome?: (outcome: ProviderCatalogOutcome) => void;
}) {
const hook = resolveProviderCatalogHook(params.provider);