From 55cf9523e015b91dda3770dd2070acc4faae5563 Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:29:57 -0600 Subject: [PATCH] feat: discover models from live provider catalogs (#112412) * feat: discover models from live provider catalogs * fix(provider-catalog): satisfy current catalog contracts * fix(deps): update fast-uri past new advisory * fix(deps): refresh fast-uri shrinkwraps * fix(openrouter): satisfy provider catalog lint * fix(agents): preserve refreshable catalog metadata * test(agents): keep catalog fallback proof within lint budget * fix(provider-catalog): honor live model contracts * fix(minimax): type discovery headers explicitly * docs(plugin-sdk): define live model discovery contract --- docs/plugins/sdk-provider-plugins.md | 57 +++- docs/providers/google.md | 7 + .../openclaw.plugin.json | 7 +- .../amazon-bedrock/openclaw.plugin.json | 5 + extensions/anthropic/openclaw.plugin.json | 2 +- extensions/anthropic/openclaw.plugin.test.ts | 2 +- extensions/anthropic/register.runtime.ts | 33 ++ extensions/arcee/index.ts | 17 +- extensions/arcee/openclaw.plugin.json | 5 + extensions/byteplus/index.ts | 30 +- extensions/byteplus/openclaw.plugin.json | 4 +- extensions/cerebras/index.ts | 1 + extensions/cerebras/openclaw.plugin.json | 2 +- extensions/cohere/index.test.ts | 38 ++- extensions/cohere/index.ts | 3 +- extensions/cohere/openclaw.plugin.json | 2 +- extensions/cohere/provider-catalog.ts | 25 ++ extensions/deepseek/index.ts | 2 + extensions/deepseek/openclaw.plugin.json | 2 +- extensions/featherless/index.ts | 8 + extensions/featherless/openclaw.plugin.json | 2 +- extensions/fireworks/index.ts | 2 + extensions/fireworks/openclaw.plugin.json | 2 +- extensions/gmi/index.ts | 1 + extensions/gmi/openclaw.plugin.json | 3 + extensions/google/google.live.test.ts | 41 ++- extensions/google/openclaw.plugin.json | 3 + extensions/google/provider-catalog.test.ts | 150 ++++++++- extensions/google/provider-catalog.ts | 138 +++++++++ extensions/google/provider-models.test.ts | 53 +++- extensions/google/provider-models.ts | 48 +-- extensions/google/provider-registration.ts | 19 ++ extensions/groq/index.ts | 23 ++ extensions/groq/openclaw.plugin.json | 2 +- extensions/huggingface/openclaw.plugin.json | 5 + extensions/litellm/index.ts | 9 +- extensions/litellm/openclaw.plugin.json | 5 + extensions/lmstudio/openclaw.plugin.json | 5 + extensions/longcat/index.ts | 2 + extensions/longcat/openclaw.plugin.json | 2 +- extensions/meta/index.ts | 1 + extensions/meta/openclaw.plugin.json | 2 +- extensions/minimax/index.test.ts | 124 ++++++++ extensions/minimax/openclaw.plugin.json | 6 + extensions/minimax/provider-catalog.ts | 20 ++ extensions/minimax/provider-registration.ts | 53 ++-- extensions/mistral/index.ts | 2 + extensions/mistral/openclaw.plugin.json | 2 +- extensions/moonshot/index.ts | 1 + extensions/moonshot/openclaw.plugin.json | 2 +- extensions/novita/index.ts | 1 + extensions/novita/openclaw.plugin.json | 3 + extensions/nvidia/openclaw.plugin.json | 2 +- extensions/ollama/openclaw.plugin.json | 1 + extensions/opencode/index.test.ts | 18 +- extensions/opencode/provider-catalog.ts | 6 + extensions/openrouter/index.test.ts | 7 + extensions/openrouter/index.ts | 10 +- extensions/openrouter/openclaw.plugin.json | 5 + .../openrouter/provider-catalog.test.ts | 120 ++++++++ extensions/openrouter/provider-catalog.ts | 144 ++++++++- extensions/qianfan/index.ts | 2 + extensions/qianfan/openclaw.plugin.json | 2 +- extensions/qwen/index.ts | 30 +- extensions/qwen/openclaw.plugin.json | 3 +- extensions/qwen/provider-catalog.test.ts | 2 +- extensions/sglang/openclaw.plugin.json | 5 + extensions/stepfun/index.ts | 23 +- extensions/stepfun/openclaw.plugin.json | 4 +- extensions/tencent/index.ts | 14 +- extensions/tencent/openclaw.plugin.json | 4 +- extensions/together/index.ts | 2 + extensions/together/openclaw.plugin.json | 2 +- .../vercel-ai-gateway/openclaw.plugin.json | 5 + extensions/vllm/openclaw.plugin.json | 5 + extensions/volcengine/index.ts | 33 +- extensions/volcengine/openclaw.plugin.json | 4 +- extensions/xai/openclaw.plugin.json | 3 + extensions/xiaomi/index.ts | 29 +- extensions/xiaomi/openclaw.plugin.json | 4 +- extensions/zai/index.ts | 24 ++ extensions/zai/openclaw.plugin.json | 2 +- .../model.static-catalog.test.ts | 4 +- .../model.static-catalog.ts | 5 +- ...rovider-catalog-live-normalize.internal.ts | 286 ++++++++++++++++++ .../provider-catalog-live-runtime.test.ts | 209 +++++++++++++ .../provider-catalog-live-runtime.ts | 137 ++++++++- src/plugin-sdk/provider-entry.ts | 35 ++- 88 files changed, 2031 insertions(+), 144 deletions(-) create mode 100644 extensions/openrouter/provider-catalog.test.ts create mode 100644 src/plugin-sdk/provider-catalog-live-normalize.internal.ts diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index 0c76968dc49e..b17ce2a68920 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -208,8 +208,61 @@ catalog, API-key auth, and dynamic model resolution. ### Live model discovery - If your provider exposes a `/models`-style API, keep the provider-specific - endpoint and row projection in your plugin and use + If your provider exposes an OpenAI-compatible `/models` API, opt the + single-provider helper into shared discovery: + + ```typescript + catalog: { + buildProvider: () => ({ + api: "openai-completions", + baseUrl: "https://api.acme-ai.com/v1", + models: [...STATIC_MODELS], + }), + buildStaticProvider: () => ({ + api: "openai-completions", + baseUrl: "https://api.acme-ai.com/v1", + models: [...STATIC_MODELS], + }), + liveModelDiscovery: true, + }, + ``` + + `liveModelDiscovery: true` is a public Plugin SDK contract with these + behaviors: + + | Area | Contract | + | --- | --- | + | Credentials | Discovery uses the catalog's resolved provider credential, preferring `discoveryApiKey` when auth supplies one. Secret-reference markers are never sent as tokens. The default request uses `Authorization: Bearer `; use `buildRequestHeaders` for another vendor auth scheme. | + | Endpoint | The default URL is `models` relative to the effective provider `baseUrl`, including an operator override when `allowExplicitBaseUrl` is enabled. Use `endpointPath` for another relative path. Use `endpointUrl: { url, requireBaseUrl }` only for a fixed vendor URL; discovery is skipped unless the effective base URL still equals `requireBaseUrl`, so a custom proxy credential is not sent to the vendor. | + | Network limits | Fetches use OpenClaw's SSRF guard, one 5-second timeout budget across pagination, a 4 MiB response limit per page, and a 50-page limit. Cross-origin pagination links are rejected; credentials are removed after a cross-origin redirect. | + | Cache | Successful, non-empty catalogs are cached for 60 seconds by provider, endpoint, and resolved credential. Empty or unusable results are not cached. | + | Filtering | Exact live IDs keep their trusted static metadata. New rows are projected conservatively as text/chat models. Disabled, archived, deprecated, explicitly non-chat, embedding, reranking, moderation, speech, image-only, and video-only rows are excluded. Use `readRows` only to select rows from a nonstandard response envelope; provider-specific model semantics still belong in a custom catalog. | + | 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. | + + For a non-Bearer or nonstandard list endpoint, pass options instead of + `true`: + + ```typescript + liveModelDiscovery: { + endpointPath: "model-catalog", + buildRequestHeaders: ({ apiKey, discoveryApiKey }) => ({ + "vendor-version": "2026-01-01", + "x-api-key": discoveryApiKey ?? apiKey ?? "", + }), + readRows: (body) => + body && typeof body === "object" && + Array.isArray((body as { models?: unknown }).models) + ? (body as { models: unknown[] }).models + : [], + }, + ``` + + Do not use `endpointUrl` as an unconditional alternate host. Its + `requireBaseUrl` check is the credential-isolation boundary for providers + whose model-list host differs from their inference host. + + If the provider needs custom model semantics rather than the conservative + OpenAI-compatible projection, keep that projection in the plugin and use `openclaw/plugin-sdk/provider-catalog-live-runtime` for the shared fetch lifecycle. The helper gives you guarded HTTP fetches, provider-auth headers, structured HTTP errors, TTL caching, and static fallback behavior without diff --git a/docs/providers/google.md b/docs/providers/google.md index b3ff521d8f56..e730ac6c249b 100644 --- a/docs/providers/google.md +++ b/docs/providers/google.md @@ -61,6 +61,13 @@ Choose your preferred auth method and follow the setup steps. `GEMINI_API_KEY` and `GOOGLE_API_KEY` are both accepted. Use whichever you already have configured. + With a configured API key, OpenClaw refreshes Google AI Studio's text-model + catalog from the Gemini `models.list` API. Newly released Gemini 3 Pro, Flash, + and Flash-Lite variants therefore appear in + `openclaw models list --provider google` without waiting for an OpenClaw + release. If discovery is unavailable, OpenClaw keeps the bundled fallback + catalog. + diff --git a/extensions/amazon-bedrock-mantle/openclaw.plugin.json b/extensions/amazon-bedrock-mantle/openclaw.plugin.json index 73301c46dec4..b441befa7863 100644 --- a/extensions/amazon-bedrock-mantle/openclaw.plugin.json +++ b/extensions/amazon-bedrock-mantle/openclaw.plugin.json @@ -32,5 +32,10 @@ "help": "When false, OpenClaw keeps the Amazon Bedrock Mantle plugin available but skips implicit startup discovery. Leave unset for default auto-detect behavior." } }, - "providers": ["amazon-bedrock-mantle"] + "providers": ["amazon-bedrock-mantle"], + "modelCatalog": { + "discovery": { + "amazon-bedrock-mantle": "refreshable" + } + } } diff --git a/extensions/amazon-bedrock/openclaw.plugin.json b/extensions/amazon-bedrock/openclaw.plugin.json index 2c5b39e59381..17d10f9a08fe 100644 --- a/extensions/amazon-bedrock/openclaw.plugin.json +++ b/extensions/amazon-bedrock/openclaw.plugin.json @@ -7,6 +7,11 @@ }, "enabledByDefault": true, "providers": ["amazon-bedrock"], + "modelCatalog": { + "discovery": { + "amazon-bedrock": "refreshable" + } + }, "contracts": { "memoryEmbeddingProviders": ["bedrock"] }, diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index e674107f536f..2a26f32e55c4 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -213,7 +213,7 @@ }, "discovery": { "claude-cli": "static", - "anthropic": "static" + "anthropic": "refreshable" } }, "modelSupport": { diff --git a/extensions/anthropic/openclaw.plugin.test.ts b/extensions/anthropic/openclaw.plugin.test.ts index 2f6fae07da32..aaf1e4ba0551 100644 --- a/extensions/anthropic/openclaw.plugin.test.ts +++ b/extensions/anthropic/openclaw.plugin.test.ts @@ -79,7 +79,7 @@ describe("Anthropic plugin manifest", () => { }); it("resolves both official Claude Haiku 4.5 API identifiers from the static catalog", () => { - expect(manifest.modelCatalog?.discovery?.anthropic).toBe("static"); + expect(manifest.modelCatalog?.discovery?.anthropic).toBe("refreshable"); const models = manifest.modelCatalog?.providers?.anthropic?.models ?? []; for (const id of ["claude-haiku-4-5", "claude-haiku-4-5-20251001"]) { diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index a697fafdd577..54b01746b5da 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -25,6 +25,8 @@ import { upsertAuthProfileWithLock, validateAnthropicSetupToken, } from "openclaw/plugin-sdk/provider-auth"; +import { buildOpenAICompatibleProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; +import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared"; import { buildProviderReplayFamilyHooks, cloneFirstTemplateModel, @@ -55,6 +57,7 @@ import { normalizeAnthropicProviderConfigForProvider, } from "./config-defaults.js"; import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js"; +import manifest from "./openclaw.plugin.json" with { type: "json" }; import { resolveClaudeCliSyntheticAuth } from "./provider-discovery.js"; import { createClaudeSessionNodeInvokePolicies } from "./session-catalog-node-commands.js"; import { registerClaudeSessionDiscovery } from "./session-catalog-registration.js"; @@ -115,6 +118,13 @@ const ANTHROPIC_SETUP_TOKEN_NOTE_LINES = [ `If you want a direct API billing path instead, use ${formatCliCommand("openclaw models auth login --provider anthropic --method api-key --set-default")} or ${formatCliCommand("openclaw models auth login --provider anthropic --method cli --set-default")}.`, ] as const; +function buildAnthropicCatalogProvider() { + return buildManifestModelProviderConfig({ + providerId: PROVIDER_ID, + catalog: manifest.modelCatalog.providers.anthropic, + }); +} + function resolveAnthropicSonnet5Cost(nowMs: number = Date.now()) { return nowMs >= ANTHROPIC_SONNET_5_STANDARD_PRICING_START_MS ? ANTHROPIC_SONNET_5_STANDARD_COST @@ -892,6 +902,29 @@ export function buildAnthropicProvider(): ProviderPlugin { }, }), ], + catalog: { + order: "simple", + run: (ctx) => + buildOpenAICompatibleProviderCatalog({ + ctx, + providerId, + buildProvider: buildAnthropicCatalogProvider, + modelDiscovery: { + endpointPath: "v1/models", + buildRequestHeaders: ({ apiKey, discoveryApiKey }) => { + const key = discoveryApiKey ?? apiKey; + return { + "anthropic-version": "2023-06-01", + ...(key ? { "x-api-key": key } : {}), + }; + }, + }, + }), + }, + staticCatalog: { + order: "simple", + run: async () => ({ provider: buildAnthropicCatalogProvider() }), + }, normalizeConfig: ({ provider, providerConfig }) => normalizeAnthropicProviderConfigForProvider({ provider, providerConfig }), applyConfigDefaults: ({ config, env }) => applyAnthropicConfigDefaults({ config, env }), diff --git a/extensions/arcee/index.ts b/extensions/arcee/index.ts index 6a1442429f1d..db5789115fb7 100644 --- a/extensions/arcee/index.ts +++ b/extensions/arcee/index.ts @@ -4,6 +4,7 @@ */ import { definePluginEntry } 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 { readConfiguredProviderCatalogEntries, type ProviderCatalogContext, @@ -74,9 +75,16 @@ function buildArceeAuthMethods() { } async function resolveArceeCatalog(ctx: ProviderCatalogContext) { - const directKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (directKey) { - return { provider: { ...buildArceeProvider(), apiKey: directKey } }; + const directAuth = ctx.resolveProviderApiKey(PROVIDER_ID); + if (directAuth.apiKey) { + return { + provider: await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: PROVIDER_ID, + providerConfig: buildArceeProvider(), + apiKey: directAuth.apiKey, + discoveryApiKey: directAuth.discoveryApiKey, + }), + }; } const openRouterKey = ctx.resolveProviderApiKey("openrouter").apiKey; @@ -120,6 +128,9 @@ export default definePluginEntry({ catalog: { run: resolveArceeCatalog, }, + staticCatalog: { + run: async () => ({ provider: buildArceeProvider() }), + }, augmentModelCatalog: ({ config }) => readConfiguredProviderCatalogEntries({ config, diff --git a/extensions/arcee/openclaw.plugin.json b/extensions/arcee/openclaw.plugin.json index 98268d2b8fba..e1a2048c6572 100644 --- a/extensions/arcee/openclaw.plugin.json +++ b/extensions/arcee/openclaw.plugin.json @@ -5,6 +5,11 @@ }, "enabledByDefault": true, "providers": ["arcee"], + "modelCatalog": { + "discovery": { + "arcee": "runtime" + } + }, "setup": { "providers": [ { diff --git a/extensions/byteplus/index.ts b/extensions/byteplus/index.ts index 7fe5ec399190..58c3d08405bf 100644 --- a/extensions/byteplus/index.ts +++ b/extensions/byteplus/index.ts @@ -3,6 +3,7 @@ */ import { definePluginEntry } 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 { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard"; import { BYTEPLUS_PROVIDER_CATALOG_ENTRIES } from "./provider-catalog.js"; import { buildBytePlusVideoGenerationProvider } from "./video-generation-provider.js"; @@ -49,20 +50,39 @@ export default definePluginEntry({ catalog: { order: "paired", run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + const auth = ctx.resolveProviderApiKey(PROVIDER_ID); + const apiKey = auth.apiKey; if (!apiKey) { return null; } return { providers: Object.fromEntries( - BYTEPLUS_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [ - id, - { ...buildProvider(), apiKey }, - ]), + await Promise.all( + BYTEPLUS_PROVIDER_CATALOG_ENTRIES.map( + async ({ id, buildProvider }) => + [ + id, + await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: id, + providerConfig: buildProvider(), + apiKey, + discoveryApiKey: auth.discoveryApiKey, + }), + ] as const, + ), + ), ), }; }, }, + staticCatalog: { + order: "paired", + run: async () => ({ + providers: Object.fromEntries( + BYTEPLUS_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [id, buildProvider()]), + ), + }), + }, augmentModelCatalog: () => BYTEPLUS_PROVIDER_CATALOG_ENTRIES.flatMap(({ id: provider, models }) => models.map((entry) => ({ diff --git a/extensions/byteplus/openclaw.plugin.json b/extensions/byteplus/openclaw.plugin.json index 0c8bf8c7db26..dca5519ee519 100644 --- a/extensions/byteplus/openclaw.plugin.json +++ b/extensions/byteplus/openclaw.plugin.json @@ -113,8 +113,8 @@ } }, "discovery": { - "byteplus": "static", - "byteplus-plan": "static" + "byteplus": "refreshable", + "byteplus-plan": "refreshable" } }, "providerAuthChoices": [ diff --git a/extensions/cerebras/index.ts b/extensions/cerebras/index.ts index 52252dbb1500..dbd474cb8f07 100644 --- a/extensions/cerebras/index.ts +++ b/extensions/cerebras/index.ts @@ -39,6 +39,7 @@ export default defineSingleProviderPluginEntry({ catalog: { buildProvider: buildCerebrasProvider, buildStaticProvider: buildCerebrasProvider, + liveModelDiscovery: true, }, }, }); diff --git a/extensions/cerebras/openclaw.plugin.json b/extensions/cerebras/openclaw.plugin.json index 66fe64e49d06..dc789e072b6b 100644 --- a/extensions/cerebras/openclaw.plugin.json +++ b/extensions/cerebras/openclaw.plugin.json @@ -56,7 +56,7 @@ } }, "discovery": { - "cerebras": "static" + "cerebras": "refreshable" } }, "setup": { diff --git a/extensions/cohere/index.test.ts b/extensions/cohere/index.test.ts index 156e1eaa8c16..54dbb7968894 100644 --- a/extensions/cohere/index.test.ts +++ b/extensions/cohere/index.test.ts @@ -6,7 +6,7 @@ import { buildOpenAICompletionsParams } from "openclaw/plugin-sdk/provider-trans import { describe, expect, it } from "vitest"; import plugin from "./index.js"; import { COHERE_COMMAND_A_PLUS_MODEL_ID } from "./models.js"; -import { buildCohereProvider } from "./provider-catalog.js"; +import { buildCohereProvider, COHERE_LIVE_MODEL_DISCOVERY } from "./provider-catalog.js"; import { createCohereCompletionsWrapper } from "./stream.js"; const COHERE_COMMAND_A_REASONING_MODEL_ID = "command-a-reasoning-08-2025"; @@ -159,6 +159,42 @@ describe("Cohere provider plugin", () => { }); }); + it("normalizes Cohere live catalog rows for chat discovery", () => { + expect(COHERE_LIVE_MODEL_DISCOVERY.endpointUrl).toEqual({ + url: "https://api.cohere.com/v1/models?endpoint=chat&page_size=1000", + requireBaseUrl: "https://api.cohere.ai/compatibility/v1", + }); + expect( + COHERE_LIVE_MODEL_DISCOVERY.readRows?.({ + models: [ + { + name: "command-fresh", + is_deprecated: false, + endpoints: ["chat"], + context_length: 256_000, + }, + { name: "command-retired", is_deprecated: true, endpoints: ["chat"] }, + ], + }), + ).toEqual([ + { + id: "command-fresh", + name: "command-fresh", + is_deprecated: false, + active: true, + endpoints: ["chat"], + context_length: 256_000, + }, + { + id: "command-retired", + name: "command-retired", + is_deprecated: true, + active: false, + endpoints: ["chat"], + }, + ]); + }); + it("uses Cohere's OpenAI-compatible completions payload fields", () => { const params = captureCoherePayload({ systemPrompt: "system", diff --git a/extensions/cohere/index.ts b/extensions/cohere/index.ts index 0e6930274699..6110428790ed 100644 --- a/extensions/cohere/index.ts +++ b/extensions/cohere/index.ts @@ -1,7 +1,7 @@ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; import { isModernCohereModelId } from "./models.js"; import { applyCohereConfig, COHERE_DEFAULT_MODEL_REF } from "./onboard.js"; -import { buildCohereProvider } from "./provider-catalog.js"; +import { buildCohereProvider, COHERE_LIVE_MODEL_DISCOVERY } from "./provider-catalog.js"; import { createCohereCompletionsWrapper } from "./stream.js"; export default defineSingleProviderPluginEntry({ @@ -31,6 +31,7 @@ export default defineSingleProviderPluginEntry({ catalog: { buildProvider: buildCohereProvider, buildStaticProvider: buildCohereProvider, + liveModelDiscovery: COHERE_LIVE_MODEL_DISCOVERY, }, wrapStreamFn: (ctx) => createCohereCompletionsWrapper(ctx.streamFn), wrapSimpleCompletionStreamFn: (ctx) => createCohereCompletionsWrapper(ctx.streamFn), diff --git a/extensions/cohere/openclaw.plugin.json b/extensions/cohere/openclaw.plugin.json index 65ec924aaea5..da36e29d4743 100644 --- a/extensions/cohere/openclaw.plugin.json +++ b/extensions/cohere/openclaw.plugin.json @@ -151,7 +151,7 @@ } }, "discovery": { - "cohere": "static" + "cohere": "refreshable" } }, "setup": { diff --git a/extensions/cohere/provider-catalog.ts b/extensions/cohere/provider-catalog.ts index d22b6256f2c9..550ff53fa2ae 100644 --- a/extensions/cohere/provider-catalog.ts +++ b/extensions/cohere/provider-catalog.ts @@ -1,6 +1,31 @@ +import type { OpenAICompatibleModelDiscoveryOptions } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { buildCohereCatalogModels, COHERE_BASE_URL } from "./models.js"; +export const COHERE_LIVE_MODEL_DISCOVERY: OpenAICompatibleModelDiscoveryOptions = { + endpointUrl: { + url: "https://api.cohere.com/v1/models?endpoint=chat&page_size=1000", + requireBaseUrl: COHERE_BASE_URL, + }, + readRows: (body) => { + if ( + !body || + typeof body !== "object" || + !Array.isArray((body as { models?: unknown }).models) + ) { + throw new Error("Cohere model catalog response must contain models[]"); + } + return (body as { models: unknown[] }).models.flatMap((row) => { + if (!row || typeof row !== "object" || Array.isArray(row)) { + return []; + } + const record = row as Record; + const modelId = typeof record.name === "string" ? record.name.trim() : ""; + return modelId ? [{ ...record, id: modelId, active: record.is_deprecated !== true }] : []; + }); + }, +}; + export function buildCohereProvider(): ModelProviderConfig { return { baseUrl: COHERE_BASE_URL, diff --git a/extensions/deepseek/index.ts b/extensions/deepseek/index.ts index e26c4f182e8f..cda1a5d3ecc5 100644 --- a/extensions/deepseek/index.ts +++ b/extensions/deepseek/index.ts @@ -40,6 +40,8 @@ export default defineSingleProviderPluginEntry({ ], catalog: { buildProvider: buildDeepSeekProvider, + buildStaticProvider: buildDeepSeekProvider, + liveModelDiscovery: true, }, augmentModelCatalog: ({ config }) => readConfiguredProviderCatalogEntries({ diff --git a/extensions/deepseek/openclaw.plugin.json b/extensions/deepseek/openclaw.plugin.json index 239796108d4a..e6065e2bb90b 100644 --- a/extensions/deepseek/openclaw.plugin.json +++ b/extensions/deepseek/openclaw.plugin.json @@ -107,7 +107,7 @@ } }, "discovery": { - "deepseek": "static" + "deepseek": "refreshable" } }, "setup": { diff --git a/extensions/featherless/index.ts b/extensions/featherless/index.ts index fe36fbe31e84..ab00c5a96740 100644 --- a/extensions/featherless/index.ts +++ b/extensions/featherless/index.ts @@ -101,6 +101,14 @@ export default defineSingleProviderPluginEntry({ buildProvider: buildFeatherlessProvider, buildStaticProvider: buildFeatherlessProvider, allowExplicitBaseUrl: true, + liveModelDiscovery: { + endpointPath: "models?capabilities=chat", + buildRequestHeaders: ({ apiKey }) => ({ + Accept: "application/json", + "User-Agent": "openclaw", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }), + }, }, augmentModelCatalog: ({ config }) => readConfiguredProviderCatalogEntries({ diff --git a/extensions/featherless/openclaw.plugin.json b/extensions/featherless/openclaw.plugin.json index b0ff8a44601d..c5ba6a1442ce 100644 --- a/extensions/featherless/openclaw.plugin.json +++ b/extensions/featherless/openclaw.plugin.json @@ -72,7 +72,7 @@ } }, "discovery": { - "featherless": "static" + "featherless": "refreshable" } }, "configSchema": { diff --git a/extensions/fireworks/index.ts b/extensions/fireworks/index.ts index 644f4685a4c7..29be3f0abcd5 100644 --- a/extensions/fireworks/index.ts +++ b/extensions/fireworks/index.ts @@ -95,7 +95,9 @@ export default defineSingleProviderPluginEntry({ ], catalog: { buildProvider: buildFireworksProvider, + buildStaticProvider: buildFireworksProvider, allowExplicitBaseUrl: true, + liveModelDiscovery: true, }, ...buildProviderReplayFamilyHooks({ family: "openai-compatible" }), wrapStreamFn: wrapFireworksProviderStream, diff --git a/extensions/fireworks/openclaw.plugin.json b/extensions/fireworks/openclaw.plugin.json index b9746bb5c776..7088f5caf926 100644 --- a/extensions/fireworks/openclaw.plugin.json +++ b/extensions/fireworks/openclaw.plugin.json @@ -70,7 +70,7 @@ } }, "discovery": { - "fireworks": "static" + "fireworks": "refreshable" } }, "configSchema": { diff --git a/extensions/gmi/index.ts b/extensions/gmi/index.ts index 30dbb807ea42..ee691228f497 100644 --- a/extensions/gmi/index.ts +++ b/extensions/gmi/index.ts @@ -35,6 +35,7 @@ export default defineSingleProviderPluginEntry({ buildProvider: buildGmiProvider, buildStaticProvider: buildGmiProvider, allowExplicitBaseUrl: true, + liveModelDiscovery: true, }, augmentModelCatalog: ({ config }) => readConfiguredProviderCatalogEntries({ diff --git a/extensions/gmi/openclaw.plugin.json b/extensions/gmi/openclaw.plugin.json index 95546efd1a81..166ce3389b03 100644 --- a/extensions/gmi/openclaw.plugin.json +++ b/extensions/gmi/openclaw.plugin.json @@ -160,6 +160,9 @@ } ] } + }, + "discovery": { + "gmi": "refreshable" } } } diff --git a/extensions/google/google.live.test.ts b/extensions/google/google.live.test.ts index 6b35daec7064..7b31ba1668f5 100644 --- a/extensions/google/google.live.test.ts +++ b/extensions/google/google.live.test.ts @@ -1,5 +1,6 @@ -import { resolveFfmpegBin } from "openclaw/plugin-sdk/media-runtime"; // Google tests cover google plugin behavior. +import { completeSimple, type Model } from "openclaw/plugin-sdk/llm"; +import { resolveFfmpegBin } from "openclaw/plugin-sdk/media-runtime"; import { registerProviderPlugin, requireRegisteredProvider, @@ -8,6 +9,7 @@ import { normalizeTranscriptForMatch } from "openclaw/plugin-sdk/provider-test-c import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-live"; import { describe, expect, it } from "vitest"; import plugin from "./index.js"; +import { buildGoogleLiveCatalogProvider } from "./provider-catalog.js"; import { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js"; const GOOGLE_API_KEY = @@ -72,6 +74,43 @@ const registerGooglePlugin = () => }); describeLive("google plugin live", () => { + it.each(["gemini-3.6-flash", "gemini-3.5-flash-lite"])( + "discovers and completes through %s", + async (modelId) => { + const provider = await buildGoogleLiveCatalogProvider({ + apiKey: "GEMINI_API_KEY", + discoveryApiKey: GOOGLE_API_KEY, + }); + const definition = provider.models.find((model) => model.id === modelId); + expect(definition, `${modelId} missing from Google models.list`).toBeDefined(); + + const response = await completeSimple( + { + ...definition!, + provider: "google", + baseUrl: provider.baseUrl, + api: "google-generative-ai", + } as Model<"google-generative-ai">, + { + messages: [ + { + role: "user", + content: "Reply with exactly: OpenClaw live catalog OK", + timestamp: Date.now(), + }, + ], + }, + { apiKey: GOOGLE_API_KEY, maxTokens: 64 }, + ); + + expect(response.stopReason).not.toBe("error"); + expect(response.content.some((block) => block.type === "text" && block.text.trim())).toBe( + true, + ); + }, + 90_000, + ); + it("synthesizes speech through the registered provider", async () => { const { speechProviders } = await registerGooglePlugin(); const provider = requireRegisteredProvider(speechProviders, "google"); diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 8cc8831cf49e..2cb4016a34ba 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -46,6 +46,9 @@ } }, "modelCatalog": { + "discovery": { + "google": "runtime" + }, "suppressions": [ { "provider": "google", diff --git a/extensions/google/provider-catalog.test.ts b/extensions/google/provider-catalog.test.ts index 9da6ae2d5af8..028985fa9815 100644 --- a/extensions/google/provider-catalog.test.ts +++ b/extensions/google/provider-catalog.test.ts @@ -1,20 +1,34 @@ // Google tests cover provider catalog plugin behavior. -import { describe, expect, it } from "vitest"; import { + clearLiveCatalogCacheForTests, + type LiveModelCatalogFetchGuard, +} from "openclaw/plugin-sdk/provider-catalog-live-runtime"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildGoogleLiveCatalogProvider, buildGoogleStaticCatalogProvider, buildGoogleVertexStaticCatalogProvider, } from "./provider-catalog.js"; describe("google provider catalog", () => { + beforeEach(() => { + clearLiveCatalogCacheForTests(); + }); + it("registers current Gemini rows for the Google Vertex provider", () => { const provider = buildGoogleVertexStaticCatalogProvider(); expect(provider.api).toBe("google-vertex"); expect(provider.baseUrl).toBe("https://{location}-aiplatform.googleapis.com"); expect(provider.models.map((model) => model.id)).toEqual( - expect.arrayContaining(["gemini-2.5-pro", "gemini-3.1-pro-preview", "gemini-3.1-flash-lite"]), + expect.arrayContaining([ + "gemini-2.5-pro", + "gemini-3.1-pro-preview", + "gemini-3.5-flash-lite", + "gemini-3.6-flash", + ]), ); - expect(provider.models.find((model) => model.id === "gemini-3.1-flash-lite")).toMatchObject({ + expect(provider.models.find((model) => model.id === "gemini-3.6-flash")).toMatchObject({ contextWindow: 1_048_576, maxTokens: 65_536, reasoning: true, @@ -26,4 +40,134 @@ describe("google provider catalog", () => { buildGoogleStaticCatalogProvider().models.map((model) => model.id), ); }); + + it("builds the authenticated text catalog from Google models.list metadata", async () => { + const release = vi.fn(async () => undefined); + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => { + const isSecondPage = new URL(url).searchParams.get("pageToken") === "page-2"; + return { + response: Response.json( + isSecondPage + ? { + models: [ + { + name: "models/gemini-3.5-flash-lite", + displayName: "Gemini 3.5 Flash-Lite", + inputTokenLimit: 1_048_576, + outputTokenLimit: 65_536, + supportedGenerationMethods: ["generateContent"], + thinking: true, + }, + { + name: "models/gemma-3-4b-it", + displayName: "Gemma 3 4B", + inputTokenLimit: 131_072, + outputTokenLimit: 8_192, + supportedGenerationMethods: ["generateContent"], + }, + ], + } + : { + models: [ + { + name: "models/gemini-3.6-flash", + displayName: "Gemini 3.6 Flash", + inputTokenLimit: 1_048_576, + outputTokenLimit: 65_536, + supportedGenerationMethods: ["generateContent", "countTokens"], + thinking: true, + }, + { + name: "models/gemma-3-1b-it", + displayName: "Gemma 3 1B", + inputTokenLimit: 32_768, + outputTokenLimit: 8_192, + supportedGenerationMethods: ["generateContent"], + }, + { + name: "models/gemini-3.1-flash-image", + displayName: "Nano Banana 2", + inputTokenLimit: 65_536, + outputTokenLimit: 32_768, + supportedGenerationMethods: ["generateContent"], + }, + { + name: "models/gemini-embedding-2-preview", + displayName: "Gemini Embedding 2", + inputTokenLimit: 8_192, + outputTokenLimit: 8_192, + supportedGenerationMethods: ["embedContent"], + }, + ], + nextPageToken: "page-2", + }, + ), + finalUrl: url, + release, + }; + }); + + const provider = await buildGoogleLiveCatalogProvider({ + apiKey: "GEMINI_API_KEY", + discoveryApiKey: "resolved-google-key", + fetchGuard, + }); + + expect(provider.apiKey).toBe("GEMINI_API_KEY"); + expect(provider.models).toEqual([ + expect.objectContaining({ + id: "gemini-3.5-flash-lite", + name: "Gemini 3.5 Flash-Lite", + reasoning: true, + contextWindow: 1_048_576, + maxTokens: 65_536, + input: ["text", "image"], + }), + expect.objectContaining({ + id: "gemini-3.6-flash", + name: "Gemini 3.6 Flash", + reasoning: true, + contextWindow: 1_048_576, + maxTokens: 65_536, + input: ["text", "image"], + }), + expect.objectContaining({ + id: "gemma-3-1b-it", + name: "Gemma 3 1B", + input: ["text"], + }), + expect.objectContaining({ + id: "gemma-3-4b-it", + name: "Gemma 3 4B", + input: ["text", "image"], + }), + ]); + const request = vi.mocked(fetchGuard).mock.calls[0]?.[0]; + expect(request?.url).toBe( + "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000", + ); + expect(new Headers(request?.init?.headers).get("x-goog-api-key")).toBe("resolved-google-key"); + expect(vi.mocked(fetchGuard).mock.calls[1]?.[0].url).toBe( + "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000&pageToken=page-2", + ); + expect(release).toHaveBeenCalledTimes(2); + }); + + it("falls back to bundled rows when live discovery is unusable", async () => { + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({ + response: Response.json({ models: [{ name: "models/gemini-3.6-flash" }] }), + finalUrl: url, + release: async () => undefined, + })); + + const provider = await buildGoogleLiveCatalogProvider({ + apiKey: "GEMINI_API_KEY", + discoveryApiKey: "resolved-google-key", + fetchGuard, + }); + + expect(provider.models.map((model) => model.id)).toEqual( + buildGoogleStaticCatalogProvider().models.map((model) => model.id), + ); + }); }); diff --git a/extensions/google/provider-catalog.ts b/extensions/google/provider-catalog.ts index cf087418ec9b..5133d49dd979 100644 --- a/extensions/google/provider-catalog.ts +++ b/extensions/google/provider-catalog.ts @@ -1,11 +1,18 @@ // Google provider module implements model/runtime integration. +import { + getCachedLiveProviderModelRows, + type LiveModelCatalogFetchGuard, +} from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import type { ModelDefinitionConfig, ModelProviderConfig, } from "openclaw/plugin-sdk/provider-model-shared"; +import { isGoogleTextGenerationModelId } from "./provider-models.js"; const GOOGLE_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; +const GOOGLE_GEMINI_MODELS_ENDPOINT = `${GOOGLE_GEMINI_BASE_URL}/models?pageSize=1000`; const GOOGLE_VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com"; +const GOOGLE_GEMINI_MODELS_CACHE_TTL_MS = 60_000; const GOOGLE_GEMINI_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const; const GOOGLE_GEMINI_TEXT_MODELS: ModelDefinitionConfig[] = [ { @@ -44,6 +51,24 @@ const GOOGLE_GEMINI_TEXT_MODELS: ModelDefinitionConfig[] = [ contextWindow: 1_048_576, maxTokens: 65_536, }, + { + id: "gemini-3.6-flash", + name: "Gemini 3.6 Flash", + reasoning: true, + input: ["text", "image"], + cost: GOOGLE_GEMINI_COST, + contextWindow: 1_048_576, + maxTokens: 65_536, + }, + { + id: "gemini-3.5-flash-lite", + name: "Gemini 3.5 Flash-Lite", + reasoning: true, + input: ["text", "image"], + cost: GOOGLE_GEMINI_COST, + contextWindow: 1_048_576, + maxTokens: 65_536, + }, { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview", @@ -81,6 +106,119 @@ export function buildGoogleStaticCatalogProvider(): ModelProviderConfig { }; } +function readGoogleLiveModels(body: unknown): readonly unknown[] { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return []; + } + const models = (body as { models?: unknown }).models; + return Array.isArray(models) ? models : []; +} + +function readString(row: Record, key: string): string | undefined { + const value = row[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readPositiveInteger(row: Record, key: string): number | undefined { + const value = row[key]; + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +function googleLiveModelInput(id: string): ModelDefinitionConfig["input"] { + if (!id.startsWith("gemma-")) { + return ["text", "image"]; + } + const isMultimodalGemma = + /^gemma-3-(?:4b|12b|27b)(?:-|$)/.test(id) || + id.startsWith("gemma-3n-") || + id.startsWith("gemma-4-"); + return isMultimodalGemma ? ["text", "image"] : ["text"]; +} + +function buildGoogleLiveModel(row: unknown): ModelDefinitionConfig | undefined { + if (!row || typeof row !== "object" || Array.isArray(row)) { + return undefined; + } + const record = row as Record; + const resourceName = readString(record, "name"); + const id = resourceName?.startsWith("models/") ? resourceName.slice("models/".length) : undefined; + const methods = record.supportedGenerationMethods; + const contextWindow = readPositiveInteger(record, "inputTokenLimit"); + const maxTokens = readPositiveInteger(record, "outputTokenLimit"); + if ( + !id || + !isGoogleTextGenerationModelId(id) || + !Array.isArray(methods) || + !methods.includes("generateContent") || + !contextWindow || + !maxTokens + ) { + return undefined; + } + return { + id, + name: readString(record, "displayName") ?? id, + reasoning: record.thinking === true, + // models.list omits modalities. Gemma has both text-only small variants and + // multimodal families, so keep this capability distinction explicit. + input: googleLiveModelInput(id), + cost: GOOGLE_GEMINI_COST, + contextWindow, + maxTokens, + }; +} + +function parseGoogleLiveModels(rows: readonly unknown[]): ModelDefinitionConfig[] { + const models = rows + .map(buildGoogleLiveModel) + .filter((model): model is ModelDefinitionConfig => Boolean(model)); + return [...new Map(models.map((model) => [model.id, model])).values()].toSorted((a, b) => + a.id.localeCompare(b.id), + ); +} + +export async function buildGoogleLiveCatalogProvider(params: { + apiKey?: string; + discoveryApiKey?: string; + fetchGuard?: LiveModelCatalogFetchGuard; + signal?: AbortSignal; +}): Promise { + const fallback = { + ...buildGoogleStaticCatalogProvider(), + ...(params.apiKey ? { apiKey: params.apiKey } : {}), + }; + try { + const rows = await getCachedLiveProviderModelRows({ + providerId: "google", + endpoint: GOOGLE_GEMINI_MODELS_ENDPOINT, + apiKey: params.apiKey, + discoveryApiKey: params.discoveryApiKey, + fetchGuard: params.fetchGuard, + signal: params.signal, + ttlMs: GOOGLE_GEMINI_MODELS_CACHE_TTL_MS, + auditContext: "google-model-discovery", + readRows: readGoogleLiveModels, + buildRequestHeaders: ({ discoveryApiKey, apiKey }) => ({ + Accept: "application/json", + ...((discoveryApiKey ?? apiKey) ? { "x-goog-api-key": discoveryApiKey ?? apiKey } : {}), + }), + shouldCacheRows: (modelRows) => parseGoogleLiveModels(modelRows).length > 0, + }); + const models = parseGoogleLiveModels(rows); + if (models.length === 0) { + return fallback; + } + return { + ...fallback, + models, + }; + } catch { + // Discovery is advisory. Offline setup, expired credentials, and transient + // provider failures retain the bundled catalog instead of hiding Google. + return fallback; + } +} + export function buildGoogleVertexStaticCatalogProvider(): ModelProviderConfig { return { baseUrl: GOOGLE_VERTEX_BASE_URL, diff --git a/extensions/google/provider-models.test.ts b/extensions/google/provider-models.test.ts index 617e92826d09..0147b135b857 100644 --- a/extensions/google/provider-models.test.ts +++ b/extensions/google/provider-models.test.ts @@ -2,7 +2,11 @@ import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; import { describe, expect, it } from "vitest"; import { createProviderDynamicModelContext as createContext } from "../test-support/provider-model-test-helpers.js"; -import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js"; +import { + isGoogleTextGenerationModelId, + isModernGoogleModel, + resolveGoogleGeminiForwardCompatModel, +} from "./provider-models.js"; function createTemplateModel( provider: string, @@ -529,4 +533,51 @@ describe("resolveGoogleGeminiForwardCompatModel", () => { reasoning: false, }); }); + + it.each([ + ["gemini-3.6-flash", "gemini-3-flash-preview"], + ["gemini-3.5-flash-lite", "gemini-3.1-flash-lite"], + ])("resolves future Gemini 3 text family %s from %s metadata", (modelId, templateId) => { + const model = resolveGoogleGeminiForwardCompatModel({ + providerId: "google", + ctx: createContext({ + provider: "google", + modelId, + models: [ + createTemplateModel("google", templateId, { + reasoning: true, + contextWindow: 1_048_576, + }), + ], + }), + }); + + expectModelFields(model, { + provider: "google", + id: modelId, + reasoning: true, + contextWindow: 1_048_576, + }); + }); + + it("keeps non-chat Gemini surfaces out of text discovery and forward compatibility", () => { + for (const modelId of [ + "gemini-3.1-flash-image", + "gemini-3.1-flash-tts-preview", + "gemini-3.1-flash-live-preview", + "gemini-2.5-flash-preview-native-audio-dialog", + ]) { + expect(isGoogleTextGenerationModelId(modelId)).toBe(false); + expect( + resolveGoogleGeminiForwardCompatModel({ + providerId: "google", + ctx: createContext({ + provider: "google", + modelId, + models: [createTemplateModel("google", "gemini-3-flash-preview")], + }), + }), + ).toBeUndefined(); + } + }); }); diff --git a/extensions/google/provider-models.ts b/extensions/google/provider-models.ts index 2690ca9a5135..14661b7b5469 100644 --- a/extensions/google/provider-models.ts +++ b/extensions/google/provider-models.ts @@ -12,12 +12,9 @@ const GOOGLE_ANTIGRAVITY_PROVIDER_ID = "google-antigravity"; const GEMINI_2_5_PRO_PREFIX = "gemini-2.5-pro"; const GEMINI_2_5_FLASH_LITE_PREFIX = "gemini-2.5-flash-lite"; const GEMINI_2_5_FLASH_PREFIX = "gemini-2.5-flash"; -const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro"; -const GEMINI_3_1_FLASH_LITE_PREFIX = "gemini-3.1-flash-lite"; -const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash"; -const GEMINI_3_FLASH_LITE_PREFIX = "gemini-3-flash-lite"; -const GEMINI_3_FLASH_PREFIX = "gemini-3-flash"; -const GEMINI_3_5_FLASH_PREFIX = "gemini-3.5-flash"; +const GEMINI_3_PRO_RE = /^gemini-3(?:\.\d+)?-pro(?:-|$)/; +const GEMINI_3_FLASH_LITE_RE = /^gemini-3(?:\.\d+)?-flash-lite(?:-|$)/; +const GEMINI_3_FLASH_RE = /^gemini-3(?:\.\d+)?-flash(?:-|$)/; const GEMINI_PRO_LATEST_ID = "gemini-pro-latest"; const GEMINI_FLASH_LATEST_ID = "gemini-flash-latest"; const GEMINI_FLASH_LITE_LATEST_ID = "gemini-flash-lite-latest"; @@ -34,6 +31,7 @@ const GEMINI_3_FLASH_ANTIGRAVITY_TEMPLATE_IDS = ["gemini-3-flash"] as const; // until a dedicated Gemma template is registered in the catalog. const GEMMA_TEMPLATE_IDS = GEMINI_3_1_FLASH_TEMPLATE_IDS; const GOOGLE_PROVIDER_PREFIX = "google/"; +const GOOGLE_NON_TEXT_MODEL_ID_MARKERS = ["-image", "-tts", "-live", "native-audio"] as const; function normalizeGeminiProRequestId(id: string): string { if (id.startsWith(GOOGLE_PROVIDER_PREFIX)) { @@ -54,6 +52,25 @@ function googleFamilyModelId(id: string): string { return id.startsWith(GOOGLE_PROVIDER_PREFIX) ? id.slice(GOOGLE_PROVIDER_PREFIX.length) : id; } +export function isGoogleTextGenerationModelId(id: string): boolean { + const lower = normalizeOptionalLowercaseString(googleFamilyModelId(id)) ?? ""; + if (GOOGLE_NON_TEXT_MODEL_ID_MARKERS.some((marker) => lower.includes(marker))) { + return false; + } + return ( + lower.startsWith(GEMINI_2_5_PRO_PREFIX) || + lower.startsWith(GEMINI_2_5_FLASH_LITE_PREFIX) || + lower.startsWith(GEMINI_2_5_FLASH_PREFIX) || + GEMINI_3_PRO_RE.test(lower) || + GEMINI_3_FLASH_LITE_RE.test(lower) || + GEMINI_3_FLASH_RE.test(lower) || + lower === GEMINI_PRO_LATEST_ID || + lower === GEMINI_FLASH_LATEST_ID || + lower === GEMINI_FLASH_LITE_LATEST_ID || + lower.startsWith(GEMMA_PREFIX) + ); +} + type GoogleForwardCompatFamily = { googleTemplateIds: readonly string[]; cliTemplateIds: readonly string[]; @@ -148,6 +165,10 @@ export function resolveGoogleGeminiForwardCompatModel(params: { const trimmed = normalizeGeminiProRequestId(params.ctx.modelId.trim()); const lower = normalizeOptionalLowercaseString(googleFamilyModelId(trimmed)) ?? ""; + if (!isGoogleTextGenerationModelId(lower)) { + return undefined; + } + let family: GoogleForwardCompatFamily; let patch: Partial | undefined; if (lower.startsWith(GEMINI_2_5_PRO_PREFIX)) { @@ -168,7 +189,7 @@ export function resolveGoogleGeminiForwardCompatModel(params: { cliTemplateIds: GEMINI_3_1_FLASH_TEMPLATE_IDS, preferExternalFirstForCli: true, }; - } else if (lower.startsWith(GEMINI_3_1_PRO_PREFIX) || lower === GEMINI_PRO_LATEST_ID) { + } else if (GEMINI_3_PRO_RE.test(lower) || lower === GEMINI_PRO_LATEST_ID) { family = { googleTemplateIds: GEMINI_3_1_PRO_TEMPLATE_IDS, cliTemplateIds: GEMINI_3_1_PRO_TEMPLATE_IDS, @@ -177,22 +198,13 @@ export function resolveGoogleGeminiForwardCompatModel(params: { if (params.providerId === "google" || params.providerId === GOOGLE_GEMINI_CLI_PROVIDER_ID) { patch = { reasoning: true }; } - } else if ( - lower.startsWith(GEMINI_3_1_FLASH_LITE_PREFIX) || - lower.startsWith(GEMINI_3_FLASH_LITE_PREFIX) || - lower === GEMINI_FLASH_LITE_LATEST_ID - ) { + } else if (GEMINI_3_FLASH_LITE_RE.test(lower) || lower === GEMINI_FLASH_LITE_LATEST_ID) { family = { googleTemplateIds: GEMINI_3_1_FLASH_LITE_TEMPLATE_IDS, cliTemplateIds: GEMINI_3_1_FLASH_LITE_TEMPLATE_IDS, antigravityTemplateIds: GEMINI_3_FLASH_ANTIGRAVITY_TEMPLATE_IDS, }; - } else if ( - lower.startsWith(GEMINI_3_1_FLASH_PREFIX) || - lower.startsWith(GEMINI_3_5_FLASH_PREFIX) || - lower.startsWith(GEMINI_3_FLASH_PREFIX) || - lower === GEMINI_FLASH_LATEST_ID - ) { + } else if (GEMINI_3_FLASH_RE.test(lower) || lower === GEMINI_FLASH_LATEST_ID) { family = { googleTemplateIds: GEMINI_3_1_FLASH_TEMPLATE_IDS, cliTemplateIds: GEMINI_3_1_FLASH_TEMPLATE_IDS, diff --git a/extensions/google/provider-registration.ts b/extensions/google/provider-registration.ts index 4a58905f4fe0..cee00bfbf13b 100644 --- a/extensions/google/provider-registration.ts +++ b/extensions/google/provider-registration.ts @@ -8,6 +8,7 @@ import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { normalizeGoogleModelId } from "./model-id.js"; import { GOOGLE_GEMINI_DEFAULT_MODEL, applyGoogleGeminiModelDefault } from "./onboard.js"; import { + buildGoogleLiveCatalogProvider, buildGoogleStaticCatalogProvider, buildGoogleVertexStaticCatalogProvider, } from "./provider-catalog.js"; @@ -80,6 +81,24 @@ export function buildGoogleProvider(): ProviderPlugin { }, }), }, + catalog: { + order: "simple", + run: async (ctx) => { + const auth = ctx.resolveProviderApiKey("google"); + if (!auth.apiKey) { + return null; + } + return { + providers: { + google: await buildGoogleLiveCatalogProvider({ + apiKey: auth.apiKey, + discoveryApiKey: auth.discoveryApiKey, + }), + "google-vertex": buildGoogleVertexStaticCatalogProvider(), + }, + }; + }, + }, normalizeModelId: ({ modelId }) => normalizeGoogleModelId(modelId), resolveDynamicModel: (ctx) => resolveGoogleGeminiForwardCompatModel({ diff --git a/extensions/groq/index.ts b/extensions/groq/index.ts index 088f33bbcd67..de265f6ce7f6 100644 --- a/extensions/groq/index.ts +++ b/extensions/groq/index.ts @@ -7,12 +7,22 @@ import { // Groq plugin entrypoint registers its OpenClaw integration. import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; +import { buildOpenAICompatibleProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; +import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared"; import { groqMediaUnderstandingProvider } from "./media-understanding-provider.js"; +import manifest from "./openclaw.plugin.json" with { type: "json" }; const GROQ_DEFAULT_MODEL_REF = "groq/llama-3.3-70b-versatile"; const GROQ_DEFAULT_MODEL_ID = "llama-3.3-70b-versatile"; const GROQ_FALLBACK_MAX_TOKENS = 1_024; +function buildGroqCatalogProvider() { + return buildManifestModelProviderConfig({ + providerId: "groq", + catalog: manifest.modelCatalog.providers.groq, + }); +} + function hasWireMaxTokens(value: unknown): boolean { if (typeof value !== "object" || value === null || Array.isArray(value)) { return false; @@ -166,6 +176,19 @@ export default definePluginEntry({ }, }), ], + catalog: { + order: "simple", + run: (ctx) => + buildOpenAICompatibleProviderCatalog({ + ctx, + providerId: "groq", + buildProvider: buildGroqCatalogProvider, + }), + }, + staticCatalog: { + order: "simple", + run: async () => ({ provider: buildGroqCatalogProvider() }), + }, wrapStreamFn: (ctx) => wrapGroqOversizedRequestRecovery( ctx.streamFn, diff --git a/extensions/groq/openclaw.plugin.json b/extensions/groq/openclaw.plugin.json index c3af5882cfa2..1b3d28ea1de7 100644 --- a/extensions/groq/openclaw.plugin.json +++ b/extensions/groq/openclaw.plugin.json @@ -182,7 +182,7 @@ } }, "discovery": { - "groq": "static" + "groq": "refreshable" } }, "contracts": { diff --git a/extensions/huggingface/openclaw.plugin.json b/extensions/huggingface/openclaw.plugin.json index d15e7b5a05ed..ff02ee4aaa78 100644 --- a/extensions/huggingface/openclaw.plugin.json +++ b/extensions/huggingface/openclaw.plugin.json @@ -6,6 +6,11 @@ }, "enabledByDefault": true, "providers": ["huggingface"], + "modelCatalog": { + "discovery": { + "huggingface": "refreshable" + } + }, "modelIdNormalization": { "providers": { "huggingface": { diff --git a/extensions/litellm/index.ts b/extensions/litellm/index.ts index 64d090f35ab3..7f6a7d8f85b6 100644 --- a/extensions/litellm/index.ts +++ b/extensions/litellm/index.ts @@ -9,7 +9,7 @@ import { createProviderApiKeyAuthMethod, normalizeOptionalSecretInput, } from "openclaw/plugin-sdk/provider-auth"; -import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog-shared"; +import { buildOpenAICompatibleProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import { buildLitellmImageGenerationProvider } from "./image-generation-provider.js"; import { applyLitellmConfig, LITELLM_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildLitellmProvider } from "./provider-catalog.js"; @@ -96,13 +96,18 @@ export default definePluginEntry({ catalog: { order: "simple", run: (ctx) => - buildSingleProviderApiKeyCatalog({ + buildOpenAICompatibleProviderCatalog({ ctx, providerId: PROVIDER_ID, buildProvider: buildLitellmProvider, allowExplicitBaseUrl: true, + modelDiscovery: { endpointPath: "v1/models" }, }), }, + staticCatalog: { + order: "simple", + run: async () => ({ provider: buildLitellmProvider() }), + }, }); api.registerImageGenerationProvider(buildLitellmImageGenerationProvider()); }, diff --git a/extensions/litellm/openclaw.plugin.json b/extensions/litellm/openclaw.plugin.json index 388512adb18f..1dfc4c77630a 100644 --- a/extensions/litellm/openclaw.plugin.json +++ b/extensions/litellm/openclaw.plugin.json @@ -5,6 +5,11 @@ }, "enabledByDefault": true, "providers": ["litellm"], + "modelCatalog": { + "discovery": { + "litellm": "runtime" + } + }, "setup": { "providers": [ { diff --git a/extensions/lmstudio/openclaw.plugin.json b/extensions/lmstudio/openclaw.plugin.json index 3aeed7e17e86..2f655cd027b9 100644 --- a/extensions/lmstudio/openclaw.plugin.json +++ b/extensions/lmstudio/openclaw.plugin.json @@ -6,6 +6,11 @@ }, "enabledByDefault": true, "providers": ["lmstudio"], + "modelCatalog": { + "discovery": { + "lmstudio": "refreshable" + } + }, "providerRequest": { "providers": { "lmstudio": { diff --git a/extensions/longcat/index.ts b/extensions/longcat/index.ts index 6ea3b288ce5b..e30d5cf7c617 100644 --- a/extensions/longcat/index.ts +++ b/extensions/longcat/index.ts @@ -42,6 +42,8 @@ export default defineSingleProviderPluginEntry({ ], catalog: { buildProvider: buildLongCatProvider, + buildStaticProvider: buildLongCatProvider, + liveModelDiscovery: true, }, ...buildProviderReplayFamilyHooks({ family: "openai-compatible", diff --git a/extensions/longcat/openclaw.plugin.json b/extensions/longcat/openclaw.plugin.json index 0bd7f1d439f4..df1ef242908f 100644 --- a/extensions/longcat/openclaw.plugin.json +++ b/extensions/longcat/openclaw.plugin.json @@ -48,7 +48,7 @@ } }, "discovery": { - "longcat": "static" + "longcat": "refreshable" } }, "setup": { diff --git a/extensions/meta/index.ts b/extensions/meta/index.ts index f3854356902a..f9d14ec1ec05 100644 --- a/extensions/meta/index.ts +++ b/extensions/meta/index.ts @@ -39,6 +39,7 @@ export default defineSingleProviderPluginEntry({ catalog: { buildProvider: buildMetaProvider, buildStaticProvider: buildMetaProvider, + liveModelDiscovery: true, }, ...buildProviderReplayFamilyHooks({ family: "openai-compatible" }), wrapStreamFn: wrapMetaProviderStream, diff --git a/extensions/meta/openclaw.plugin.json b/extensions/meta/openclaw.plugin.json index 1316554d4bfd..e727ce955db9 100644 --- a/extensions/meta/openclaw.plugin.json +++ b/extensions/meta/openclaw.plugin.json @@ -61,7 +61,7 @@ } }, "discovery": { - "meta": "static" + "meta": "refreshable" } }, "setup": { diff --git a/extensions/minimax/index.test.ts b/extensions/minimax/index.test.ts index fc1c1e44f4f2..03c698bddc9a 100644 --- a/extensions/minimax/index.test.ts +++ b/extensions/minimax/index.test.ts @@ -7,7 +7,9 @@ import { registerProviderPlugin, 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 { buildMinimaxModelDiscovery } from "./provider-catalog.js"; import { registerMinimaxProviders } from "./provider-registration.js"; import { createMiniMaxWebSearchProvider } from "./src/minimax-web-search-provider.js"; @@ -29,9 +31,131 @@ const minimaxProviderPlugin = { afterEach(() => { vi.unstubAllEnvs(); + vi.unstubAllGlobals(); }); describe("minimax provider hooks", () => { + it("uses the Anthropic model-list route and X-Api-Key auth", () => { + const discovery = buildMinimaxModelDiscovery(); + const headers = new Headers( + discovery.buildRequestHeaders?.({ apiKey: "api-key", discoveryApiKey: "discovery-key" }), + ); + + expect(discovery.endpointPath).toBe("v1/models"); + expect(headers.get("x-api-key")).toBe("discovery-key"); + expect(headers.get("authorization")).toBeNull(); + }); + + it("preserves Bearer auth for portal OAuth model discovery", () => { + const discovery = buildMinimaxModelDiscovery("oauth"); + const headers = new Headers( + discovery.buildRequestHeaders?.({ apiKey: "marker", discoveryApiKey: "oauth-token" }), + ); + + expect(headers.get("authorization")).toBe("Bearer oauth-token"); + expect(headers.get("x-api-key")).toBeNull(); + }); + + it("keeps explicit portal API keys ahead of stored OAuth profiles", async () => { + const { providers } = await registerProviderPlugin({ + plugin: minimaxProviderPlugin, + id: "minimax", + name: "MiniMax Provider", + }); + const portalProvider = requireRegisteredProvider(providers, "minimax-portal"); + + const catalog = await portalProvider.catalog?.run({ + env: {}, + config: { + models: { + providers: { + "minimax-portal": { + baseUrl: "https://api.minimax.io/anthropic", + apiKey: "explicit-key", + models: [], + }, + }, + }, + }, + resolveProviderApiKey: () => ({ + apiKey: "explicit-key", + discoveryApiKey: "explicit-key", + }), + resolveProviderAuth: () => ({ + apiKey: MINIMAX_OAUTH_MARKER, + discoveryApiKey: "oauth-token", + mode: "oauth", + source: "profile", + }), + } as never); + + const provider = catalog && "provider" in catalog ? catalog.provider : undefined; + expect(provider?.apiKey).toBe("explicit-key"); + }); + + it("uses Bearer discovery auth for MINIMAX_OAUTH_TOKEN", async () => { + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(JSON.stringify({ data: [{ id: "MiniMax-M3", object: "model" }] })), + ); + vi.stubGlobal("fetch", fetchMock); + const { providers } = await registerProviderPlugin({ + plugin: minimaxProviderPlugin, + id: "minimax", + name: "MiniMax Provider", + }); + const portalProvider = requireRegisteredProvider(providers, "minimax-portal"); + + await portalProvider.catalog?.run({ + env: { MINIMAX_OAUTH_TOKEN: "oauth-token" }, + config: {}, + resolveProviderApiKey: () => ({ + apiKey: "MINIMAX_OAUTH_TOKEN", + discoveryApiKey: "oauth-token", + }), + resolveProviderAuth: () => ({ + apiKey: "MINIMAX_OAUTH_TOKEN", + discoveryApiKey: "oauth-token", + mode: "api_key", + source: "env", + }), + } as never); + + const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); + expect(headers.get("authorization")).toBe("Bearer oauth-token"); + expect(headers.get("x-api-key")).toBeNull(); + }); + + it("uses Bearer discovery auth for a selected token profile", async () => { + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(JSON.stringify({ data: [{ id: "MiniMax-M3", object: "model" }] })), + ); + vi.stubGlobal("fetch", fetchMock); + const { providers } = await registerProviderPlugin({ + plugin: minimaxProviderPlugin, + id: "minimax", + name: "MiniMax Provider", + }); + const portalProvider = requireRegisteredProvider(providers, "minimax-portal"); + + await portalProvider.catalog?.run({ + env: {}, + config: {}, + resolveProviderApiKey: () => ({ apiKey: undefined }), + resolveProviderAuth: () => ({ + apiKey: "token-marker", + discoveryApiKey: "profile-token", + mode: "token", + source: "profile", + }), + } as never); + + const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); + expect(headers.get("authorization")).toBe("Bearer profile-token"); + expect(headers.get("x-api-key")).toBeNull(); + }); + it("declares CN provider auth aliases in the manifest", () => { const pluginJson = JSON.parse( readFileSync(resolve(import.meta.dirname, "openclaw.plugin.json"), "utf-8"), diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index 2893bb4cf93e..659d4228fda4 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -7,6 +7,12 @@ "enabledByDefault": true, "legacyPluginIds": ["minimax-portal-auth"], "providers": ["minimax", "minimax-portal"], + "modelCatalog": { + "discovery": { + "minimax": "runtime", + "minimax-portal": "runtime" + } + }, "providerEndpoints": [ { "endpointClass": "minimax-native", diff --git a/extensions/minimax/provider-catalog.ts b/extensions/minimax/provider-catalog.ts index a96888bec541..66cbb9107599 100644 --- a/extensions/minimax/provider-catalog.ts +++ b/extensions/minimax/provider-catalog.ts @@ -1,3 +1,4 @@ +import type { OpenAICompatibleModelDiscoveryOptions } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; // Minimax provider module implements model/runtime integration. import type { ModelDefinitionConfig, @@ -10,6 +11,25 @@ import { } from "./model-definitions.js"; import { MINIMAX_TEXT_MODEL_CATALOG, MINIMAX_TEXT_MODEL_ORDER } from "./provider-models.js"; +export function buildMinimaxModelDiscovery( + authMode: "api_key" | "oauth" = "api_key", +): OpenAICompatibleModelDiscoveryOptions { + return { + endpointPath: "v1/models", + // API-key discovery follows MiniMax's documented X-Api-Key contract; + // portal OAuth keeps the Bearer scheme used by its inference transport. + buildRequestHeaders: ({ apiKey, discoveryApiKey }): HeadersInit => { + const requestApiKey = discoveryApiKey ?? apiKey; + if (!requestApiKey) { + return {}; + } + return authMode === "oauth" + ? { Authorization: `Bearer ${requestApiKey}` } + : { "X-Api-Key": requestApiKey }; + }, + }; +} + export function resolveMinimaxCatalogBaseUrl(env: NodeJS.ProcessEnv = process.env): string { const rawHost = env.MINIMAX_API_HOST?.trim(); if (!rawHost) { diff --git a/extensions/minimax/provider-registration.ts b/extensions/minimax/provider-registration.ts index 72bc9105a785..4076f44ab22a 100644 --- a/extensions/minimax/provider-registration.ts +++ b/extensions/minimax/provider-registration.ts @@ -9,13 +9,10 @@ import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, } from "openclaw/plugin-sdk/plugin-entry"; -import { - MINIMAX_OAUTH_MARKER, - ensureAuthProfileStore, - listProfilesForProvider, -} from "openclaw/plugin-sdk/provider-auth"; +import { MINIMAX_OAUTH_MARKER } from "openclaw/plugin-sdk/provider-auth"; 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 type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { buildProviderReplayFamilyHooks, @@ -34,6 +31,7 @@ import { DEFAULT_MINIMAX_MAX_TOKENS, resolveMinimaxApiCost } from "./model-defin import type { MiniMaxRegion } from "./oauth.js"; import { applyMinimaxApiConfig, applyMinimaxApiConfigCn } from "./onboard.js"; import { + buildMinimaxModelDiscovery, buildMinimaxPortalProvider, buildMinimaxProvider, resolveMinimaxCatalogBaseUrl, @@ -134,38 +132,53 @@ function resolveMinimaxDynamicModel(params: { }); } -function resolveApiCatalog(ctx: ProviderCatalogContext) { - const apiKey = ctx.resolveProviderApiKey(API_PROVIDER_ID).apiKey; - if (!apiKey) { +async function resolveApiCatalog(ctx: ProviderCatalogContext) { + const auth = ctx.resolveProviderApiKey(API_PROVIDER_ID); + if (!auth.apiKey) { return null; } return { - provider: { - ...buildMinimaxProvider(ctx.env), - apiKey, - }, + provider: await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: API_PROVIDER_ID, + providerConfig: buildMinimaxProvider(ctx.env), + apiKey: auth.apiKey, + discoveryApiKey: auth.discoveryApiKey, + modelDiscovery: buildMinimaxModelDiscovery(), + }), }; } -function resolvePortalCatalog(ctx: ProviderCatalogContext) { +async function resolvePortalCatalog(ctx: ProviderCatalogContext) { const explicitProvider = ctx.config.models?.providers?.[PORTAL_PROVIDER_ID]; - const envApiKey = ctx.resolveProviderApiKey(PORTAL_PROVIDER_ID).apiKey; - const authStore = ensureAuthProfileStore(ctx.agentDir, { - allowKeychainPrompt: false, + const apiKeyAuth = ctx.resolveProviderApiKey(PORTAL_PROVIDER_ID); + const profileAuth = ctx.resolveProviderAuth(PORTAL_PROVIDER_ID, { + oauthMarker: MINIMAX_OAUTH_MARKER, }); - const hasProfiles = listProfilesForProvider(authStore, PORTAL_PROVIDER_ID).length > 0; const explicitApiKey = normalizeOptionalString(explicitProvider?.apiKey); - const apiKey = envApiKey ?? explicitApiKey ?? (hasProfiles ? MINIMAX_OAUTH_MARKER : undefined); + const apiKey = apiKeyAuth.apiKey ?? explicitApiKey ?? profileAuth.apiKey; if (!apiKey) { return null; } + const usesPortalBearerAuth = + apiKeyAuth.apiKey === "MINIMAX_OAUTH_TOKEN" || + (profileAuth.mode === "token" && profileAuth.apiKey === apiKey) || + (!apiKeyAuth.apiKey && !explicitApiKey && profileAuth.mode === "oauth"); const explicitBaseUrl = normalizeOptionalString(explicitProvider?.baseUrl); + const providerConfig = buildPortalProviderCatalog({ + baseUrl: explicitBaseUrl || buildMinimaxPortalProvider(ctx.env).baseUrl, + apiKey, + }); return { - provider: buildPortalProviderCatalog({ - baseUrl: explicitBaseUrl || buildMinimaxPortalProvider(ctx.env).baseUrl, + provider: await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: PORTAL_PROVIDER_ID, + providerConfig, apiKey, + discoveryApiKey: + apiKeyAuth.discoveryApiKey ?? + (usesPortalBearerAuth ? profileAuth.discoveryApiKey : undefined), + modelDiscovery: buildMinimaxModelDiscovery(usesPortalBearerAuth ? "oauth" : "api_key"), }), }; } diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index 395b41f1fe39..fc0ed1885b41 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -45,7 +45,9 @@ export default defineSingleProviderPluginEntry({ ], catalog: { buildProvider: buildMistralProvider, + buildStaticProvider: buildMistralProvider, allowExplicitBaseUrl: true, + liveModelDiscovery: true, }, matchesContextOverflowError: ({ errorMessage }) => /\bmistral\b.*(?:input.*too long|token limit.*exceeded)/i.test(errorMessage), diff --git a/extensions/mistral/openclaw.plugin.json b/extensions/mistral/openclaw.plugin.json index 811af57df926..640beab3c8c8 100644 --- a/extensions/mistral/openclaw.plugin.json +++ b/extensions/mistral/openclaw.plugin.json @@ -150,7 +150,7 @@ } }, "discovery": { - "mistral": "static" + "mistral": "refreshable" } }, "setup": { diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 5b4be4c6f464..7771a0527f6b 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -58,6 +58,7 @@ export default defineSingleProviderPluginEntry({ buildProvider: buildMoonshotProvider, buildStaticProvider: buildMoonshotProvider, allowExplicitBaseUrl: true, + liveModelDiscovery: true, }, applyNativeStreamingUsageCompat: ({ providerConfig }) => applyMoonshotNativeStreamingUsageCompat(providerConfig), diff --git a/extensions/moonshot/openclaw.plugin.json b/extensions/moonshot/openclaw.plugin.json index b432ba04e86d..8bd8630ed278 100644 --- a/extensions/moonshot/openclaw.plugin.json +++ b/extensions/moonshot/openclaw.plugin.json @@ -135,7 +135,7 @@ } }, "discovery": { - "moonshot": "static" + "moonshot": "refreshable" } }, "setup": { diff --git a/extensions/novita/index.ts b/extensions/novita/index.ts index 6f8a122b94b5..f03148800ad8 100644 --- a/extensions/novita/index.ts +++ b/extensions/novita/index.ts @@ -35,6 +35,7 @@ export default defineSingleProviderPluginEntry({ buildProvider: buildNovitaProvider, buildStaticProvider: buildNovitaProvider, allowExplicitBaseUrl: true, + liveModelDiscovery: true, }, augmentModelCatalog: ({ config }) => readConfiguredProviderCatalogEntries({ diff --git a/extensions/novita/openclaw.plugin.json b/extensions/novita/openclaw.plugin.json index aa0e4f6d8f21..f499987330a5 100644 --- a/extensions/novita/openclaw.plugin.json +++ b/extensions/novita/openclaw.plugin.json @@ -158,6 +158,9 @@ } ] } + }, + "discovery": { + "novita": "refreshable" } } } diff --git a/extensions/nvidia/openclaw.plugin.json b/extensions/nvidia/openclaw.plugin.json index 7fe523d1f200..27c69b439cd0 100644 --- a/extensions/nvidia/openclaw.plugin.json +++ b/extensions/nvidia/openclaw.plugin.json @@ -237,7 +237,7 @@ } }, "discovery": { - "nvidia": "static" + "nvidia": "refreshable" } }, "setup": { diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index 4c77f8e90b28..3020693f752f 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -156,6 +156,7 @@ } }, "discovery": { + "ollama": "refreshable", "ollama-cloud": "refreshable" } }, diff --git a/extensions/opencode/index.test.ts b/extensions/opencode/index.test.ts index b8561dd41220..64ae0bf167b3 100644 --- a/extensions/opencode/index.test.ts +++ b/extensions/opencode/index.test.ts @@ -94,7 +94,9 @@ describe("opencode provider plugin", () => { "claude-sonnet-4-5", "claude-sonnet-4", "claude-haiku-4-5", + "gemini-3.6-flash", "gemini-3.5-flash", + "gemini-3.5-flash-lite", "gemini-3.1-pro", "gemini-3-flash", "gpt-5.6-sol", @@ -209,6 +211,18 @@ describe("opencode provider plugin", () => { api: "google-generative-ai", baseUrl: "https://opencode.ai/zen/v1", }); + expect(requireMapEntry(models, "gemini-3.6-flash")).toMatchObject({ + name: "Gemini 3.6 Flash", + contextWindow: 1_048_576, + maxTokens: 65_536, + cost: { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, + }); + expect(requireMapEntry(models, "gemini-3.5-flash-lite")).toMatchObject({ + name: "Gemini 3.5 Flash-Lite", + contextWindow: 1_048_576, + maxTokens: 65_536, + cost: { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, + }); expect(requireMapEntry(models, "minimax-m2.7")).toMatchObject({ api: "openai-completions", baseUrl: "https://opencode.ai/zen/v1", @@ -459,7 +473,7 @@ describe("opencode provider plugin", () => { throw new Error("expected OpenCode Zen static provider"); } - expect(result.provider.models).toHaveLength(55); + expect(result.provider.models).toHaveLength(57); expect(result.provider.models.map((model) => model.id)).toContain("claude-opus-4-8"); expect(result.provider.models.map((model) => model.id)).toContain("claude-sonnet-5"); expect(result.provider.models.map((model) => model.id)).toContain("glm-5.2"); @@ -483,7 +497,7 @@ describe("opencode provider plugin", () => { throw new Error("expected registered OpenCode Zen static provider"); } - expect(result.provider.models).toHaveLength(55); + expect(result.provider.models).toHaveLength(57); expect(result.provider.models.map((model) => model.id)).toContain("claude-sonnet-5"); expect(result.provider.models.map((model) => model.id)).toContain("gpt-5.6-sol"); expect(result.provider.models.map((model) => model.id)).toContain("minimax-m3"); diff --git a/extensions/opencode/provider-catalog.ts b/extensions/opencode/provider-catalog.ts index b895bf734c1a..0ea7b3744ac3 100644 --- a/extensions/opencode/provider-catalog.ts +++ b/extensions/opencode/provider-catalog.ts @@ -92,6 +92,8 @@ const MODEL_COSTS: Record = { ], }, "gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, + "gemini-3.5-flash-lite": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, + "gemini-3.6-flash": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, "gpt-5.6-luna": { input: 1, output: 6, @@ -204,6 +206,8 @@ const MODEL_NAMES: Record = { "gemini-3-flash": "Gemini 3 Flash", "gemini-3.1-pro": "Gemini 3.1 Pro", "gemini-3.5-flash": "Gemini 3.5 Flash", + "gemini-3.5-flash-lite": "Gemini 3.5 Flash-Lite", + "gemini-3.6-flash": "Gemini 3.6 Flash", "gpt-5.6-luna": "GPT-5.6 Luna", "gpt-5.6-sol": "GPT-5.6 Sol", "gpt-5.6-terra": "GPT-5.6 Terra", @@ -402,7 +406,9 @@ const OPENCODE_ZEN_MODELS = [ "claude-sonnet-4-5", "claude-sonnet-4", "claude-haiku-4-5", + "gemini-3.6-flash", "gemini-3.5-flash", + "gemini-3.5-flash-lite", "gemini-3.1-pro", "gemini-3-flash", "gpt-5.6-sol", diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index d6b80ea1d064..12002a3d00cb 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -57,6 +57,9 @@ function createOpenRouterDoneStreamWithoutGeneration() { } type OpenRouterManifest = { + modelCatalog?: { + discovery?: Record; + }; providerAuthChoices?: Array<{ provider?: string; method?: string; @@ -76,6 +79,10 @@ function readManifest(): OpenRouterManifest { } describe("openrouter provider hooks", () => { + it("declares runtime text catalog discovery", () => { + expect(readManifest().modelCatalog?.discovery).toEqual({ openrouter: "runtime" }); + }); + it("registers OpenRouter speech alongside model, media, and catalog providers", async () => { const { providers, diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index b35598b44185..bebf95c8c482 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -23,6 +23,7 @@ import { buildOpenRouterMusicGenerationProvider } from "./music-generation-provi import { createOpenRouterOAuthAuthMethod } from "./oauth.js"; import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js"; import { + buildOpenrouterLiveProvider, buildOpenrouterProvider, isOpenRouterProxyReasoningUnsupportedModel, normalizeOpenRouterBaseUrl, @@ -312,15 +313,16 @@ export default definePluginEntry({ catalog: { order: "simple", run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + const auth = ctx.resolveProviderApiKey(PROVIDER_ID); + const apiKey = auth.apiKey; if (!apiKey) { return null; } return { - provider: { - ...buildOpenrouterProvider(), + provider: await buildOpenrouterLiveProvider({ apiKey, - }, + discoveryApiKey: auth.discoveryApiKey, + }), }; }, }, diff --git a/extensions/openrouter/openclaw.plugin.json b/extensions/openrouter/openclaw.plugin.json index f4c9144f6b23..dce4ca209573 100644 --- a/extensions/openrouter/openclaw.plugin.json +++ b/extensions/openrouter/openclaw.plugin.json @@ -6,6 +6,11 @@ }, "enabledByDefault": true, "providers": ["openrouter"], + "modelCatalog": { + "discovery": { + "openrouter": "runtime" + } + }, "modelIdNormalization": { "providers": { "openrouter": { diff --git a/extensions/openrouter/provider-catalog.test.ts b/extensions/openrouter/provider-catalog.test.ts new file mode 100644 index 000000000000..8b33c00a0ae7 --- /dev/null +++ b/extensions/openrouter/provider-catalog.test.ts @@ -0,0 +1,120 @@ +import { + clearLiveCatalogCacheForTests, + 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"; + +describe("OpenRouter provider catalog", () => { + beforeEach(() => { + clearLiveCatalogCacheForTests(); + }); + + it("discovers text models and preserves bundled routes", async () => { + const release = vi.fn(async () => undefined); + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({ + response: Response.json({ + data: [ + { + id: "google/gemini-3.6-flash", + name: "Google: Gemini 3.6 Flash", + architecture: { + input_modalities: ["text", "image", "audio", "video"], + output_modalities: ["text"], + }, + supported_parameters: ["reasoning", "tools"], + context_length: 1_048_576, + top_provider: { + context_length: 1_048_576, + max_completion_tokens: 65_536, + }, + pricing: { + prompt: "0.0000015", + completion: "0.0000075", + input_cache_read: "0.00000015", + }, + }, + { + id: "google/gemini-3.5-flash-lite", + architecture: { modality: "text+image->text" }, + supported_parameters: ["include_reasoning"], + context_length: 1_048_576, + max_completion_tokens: 65_536, + pricing: { prompt: "0.0000003", completion: "0.0000025" }, + }, + { + id: "google/gemini-3.1-flash-image", + architecture: { modality: "text+image->image" }, + context_length: 65_536, + }, + ], + }), + finalUrl: url, + release, + })); + + const provider = await buildOpenrouterLiveProvider({ + apiKey: "OPENROUTER_API_KEY", + discoveryApiKey: "resolved-openrouter-key", + fetchGuard, + }); + + expect(provider.apiKey).toBe("OPENROUTER_API_KEY"); + expect(provider.models.map((model) => model.id)).toEqual( + expect.arrayContaining([ + "openrouter/auto", + "google/gemini-3.5-flash-lite", + "google/gemini-3.6-flash", + ]), + ); + expect(provider.models.map((model) => model.id)).not.toContain("google/gemini-3.1-flash-image"); + expect(provider.models.find((model) => model.id === "google/gemini-3.6-flash")).toMatchObject({ + name: "Google: Gemini 3.6 Flash", + reasoning: true, + input: ["text", "image"], + contextWindow: 1_048_576, + maxTokens: 65_536, + cost: { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, + }); + expect( + new Headers(vi.mocked(fetchGuard).mock.calls[0]?.[0].init?.headers).get("authorization"), + ).toBe("Bearer resolved-openrouter-key"); + expect(release).toHaveBeenCalledOnce(); + }); + + it("caches live discovery and falls back to bundled rows", async () => { + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({ + response: Response.json({ + data: [ + { + id: "google/gemini-3.6-flash", + architecture: { modality: "text->text" }, + }, + ], + }), + finalUrl: url, + release: async () => undefined, + })); + + await buildOpenrouterLiveProvider({ + apiKey: "runtime-a", + discoveryApiKey: "discovery-a", + fetchGuard, + }); + await buildOpenrouterLiveProvider({ + apiKey: "runtime-b", + discoveryApiKey: "discovery-a", + fetchGuard, + }); + expect(fetchGuard).toHaveBeenCalledOnce(); + + 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); + }); +}); diff --git a/extensions/openrouter/provider-catalog.ts b/extensions/openrouter/provider-catalog.ts index 62ddf1b229b2..76d7f743eea0 100644 --- a/extensions/openrouter/provider-catalog.ts +++ b/extensions/openrouter/provider-catalog.ts @@ -1,8 +1,17 @@ // Openrouter provider module implements model/runtime integration. -import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import { + getCachedLiveProviderModelRows, + type LiveModelCatalogFetchGuard, +} from "openclaw/plugin-sdk/provider-catalog-live-runtime"; +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-model-shared"; export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +const OPENROUTER_MODELS_ENDPOINT = `${OPENROUTER_BASE_URL}/models`; const OPENROUTER_LEGACY_BASE_URL = "https://openrouter.ai/v1"; +const OPENROUTER_MODELS_CACHE_TTL_MS = 60_000; const OPENROUTER_DEFAULT_MODEL_ID = "openrouter/auto"; const OPENROUTER_DEFAULT_CONTEXT_WINDOW = 200000; const OPENROUTER_DEFAULT_MAX_TOKENS = 8192; @@ -87,3 +96,136 @@ export function buildOpenrouterProvider(): ModelProviderConfig { ], }; } + +function readRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readString(record: Record | undefined, key: string): string | undefined { + const value = record?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readPositiveInteger( + record: Record | undefined, + key: string, +): number | undefined { + const value = record?.[key]; + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +function readStringArray(record: Record | undefined, key: string): string[] { + const value = record?.[key]; + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function readTokenPrice(record: Record | undefined, key: string): number { + const value = record?.[key]; + const parsed = + typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN; + return Number.isFinite(parsed) && parsed >= 0 ? parsed * 1_000_000 : 0; +} + +function readOpenRouterModalities( + architecture: Record | undefined, + direction: "input" | "output", +): string[] { + const explicit = readStringArray(architecture, `${direction}_modalities`); + if (explicit.length > 0) { + return explicit; + } + const modality = readString(architecture, "modality"); + if (!modality) { + return []; + } + const [input = "", output = ""] = modality.split("->", 2); + return (direction === "input" ? input : output).split("+").filter(Boolean); +} + +function buildOpenRouterLiveModel(row: unknown): ModelDefinitionConfig | undefined { + const record = readRecord(row); + const id = readString(record, "id"); + const architecture = readRecord(record?.architecture); + const outputModalities = readOpenRouterModalities(architecture, "output"); + if (!id || (outputModalities.length > 0 && !outputModalities.includes("text"))) { + return undefined; + } + const inputModalities = readOpenRouterModalities(architecture, "input"); + const supportedParameters = readStringArray(record, "supported_parameters"); + const topProvider = readRecord(record?.top_provider); + const pricing = readRecord(record?.pricing); + return { + id, + name: readString(record, "name") ?? id, + reasoning: + supportedParameters.includes("reasoning") || + supportedParameters.includes("include_reasoning"), + input: inputModalities.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: readTokenPrice(pricing, "prompt"), + output: readTokenPrice(pricing, "completion"), + cacheRead: readTokenPrice(pricing, "input_cache_read"), + cacheWrite: readTokenPrice(pricing, "input_cache_write"), + }, + contextWindow: + readPositiveInteger(topProvider, "context_length") ?? + readPositiveInteger(record, "context_length") ?? + OPENROUTER_DEFAULT_CONTEXT_WINDOW, + maxTokens: + readPositiveInteger(topProvider, "max_completion_tokens") ?? + readPositiveInteger(record, "max_completion_tokens") ?? + readPositiveInteger(record, "max_output_tokens") ?? + OPENROUTER_DEFAULT_MAX_TOKENS, + }; +} + +function parseOpenRouterLiveModels(rows: readonly unknown[]): ModelDefinitionConfig[] { + const models = rows + .map(buildOpenRouterLiveModel) + .filter((model): model is ModelDefinitionConfig => Boolean(model)); + return [...new Map(models.map((model) => [model.id, model])).values()]; +} + +export async function buildOpenrouterLiveProvider(params: { + apiKey?: string; + discoveryApiKey?: string; + fetchGuard?: LiveModelCatalogFetchGuard; + signal?: AbortSignal; +}): Promise { + const fallback = { + ...buildOpenrouterProvider(), + ...(params.apiKey ? { apiKey: params.apiKey } : {}), + }; + try { + const rows = await getCachedLiveProviderModelRows({ + providerId: "openrouter", + endpoint: OPENROUTER_MODELS_ENDPOINT, + apiKey: params.apiKey, + discoveryApiKey: params.discoveryApiKey, + fetchGuard: params.fetchGuard, + signal: params.signal, + ttlMs: OPENROUTER_MODELS_CACHE_TTL_MS, + auditContext: "openrouter-model-discovery", + shouldCacheRows: (modelRows) => parseOpenRouterLiveModels(modelRows).length > 0, + }); + const liveModels = parseOpenRouterLiveModels(rows); + if (liveModels.length === 0) { + return fallback; + } + const models = new Map(fallback.models.map((model) => [model.id, model])); + for (const model of liveModels) { + models.set(model.id, model); + } + return { + ...fallback, + models: [...models.values()].toSorted((a, b) => a.id.localeCompare(b.id)), + }; + } catch { + // Discovery is advisory; retain the bundled seed when OpenRouter is unavailable. + return fallback; + } +} diff --git a/extensions/qianfan/index.ts b/extensions/qianfan/index.ts index 0b7685a585fd..be2bb68827e8 100644 --- a/extensions/qianfan/index.ts +++ b/extensions/qianfan/index.ts @@ -27,6 +27,8 @@ export default defineSingleProviderPluginEntry({ ], catalog: { buildProvider: buildQianfanProvider, + buildStaticProvider: buildQianfanProvider, + liveModelDiscovery: true, }, }, }); diff --git a/extensions/qianfan/openclaw.plugin.json b/extensions/qianfan/openclaw.plugin.json index 0e809755fb66..9698afa1d7d2 100644 --- a/extensions/qianfan/openclaw.plugin.json +++ b/extensions/qianfan/openclaw.plugin.json @@ -53,7 +53,7 @@ } }, "discovery": { - "qianfan": "static" + "qianfan": "refreshable" } }, "providerAuthChoices": [ diff --git a/extensions/qwen/index.ts b/extensions/qwen/index.ts index 2799c3afbc42..4184a690e5f3 100644 --- a/extensions/qwen/index.ts +++ b/extensions/qwen/index.ts @@ -1,5 +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 { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; import { applyQwenNativeStreamingUsageCompat } from "./api.js"; import { buildQwenMediaUnderstandingProvider } from "./media-understanding-provider.js"; @@ -244,18 +245,21 @@ export default defineSingleProviderPluginEntry({ ], catalog: { run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { + const auth = ctx.resolveProviderApiKey(PROVIDER_ID); + if (!auth.apiKey) { return null; } const baseUrl = resolveConfiguredQwenBaseUrl(ctx.config) ?? QWEN_BASE_URL; return { - provider: { - ...buildQwenProvider({ baseUrl }), - apiKey, - }, + provider: await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: PROVIDER_ID, + providerConfig: buildQwenProvider({ baseUrl }), + apiKey: auth.apiKey, + discoveryApiKey: auth.discoveryApiKey, + }), }; }, + staticRun: async () => ({ provider: buildQwenProvider() }), }, applyNativeStreamingUsageCompat: ({ providerConfig }) => applyQwenNativeStreamingUsageCompat(providerConfig), @@ -280,16 +284,18 @@ export default defineSingleProviderPluginEntry({ catalog: { order: "simple", run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(QWEN_TOKEN_PLAN_PROVIDER_ID).apiKey; - if (!apiKey) { + const auth = ctx.resolveProviderApiKey(QWEN_TOKEN_PLAN_PROVIDER_ID); + if (!auth.apiKey) { return null; } const baseUrl = resolveConfiguredQwenTokenPlanBaseUrl(ctx.config); return { - provider: { - ...buildQwenTokenPlanProvider({ baseUrl }), - apiKey, - }, + provider: await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: QWEN_TOKEN_PLAN_PROVIDER_ID, + providerConfig: buildQwenTokenPlanProvider({ baseUrl }), + apiKey: auth.apiKey, + discoveryApiKey: auth.discoveryApiKey, + }), }; }, }, diff --git a/extensions/qwen/openclaw.plugin.json b/extensions/qwen/openclaw.plugin.json index d7151cc74cd2..08d9118985aa 100644 --- a/extensions/qwen/openclaw.plugin.json +++ b/extensions/qwen/openclaw.plugin.json @@ -222,7 +222,8 @@ } }, "discovery": { - "qwen-token-plan": "static" + "qwen": "runtime", + "qwen-token-plan": "refreshable" } }, "contracts": { diff --git a/extensions/qwen/provider-catalog.test.ts b/extensions/qwen/provider-catalog.test.ts index c3c630a70d24..d14ef2c4057e 100644 --- a/extensions/qwen/provider-catalog.test.ts +++ b/extensions/qwen/provider-catalog.test.ts @@ -123,7 +123,7 @@ describe("qwen token plan provider catalog", () => { ]); expect(provider.models.every((model) => model.reasoning)).toBe(true); expect(manifest.modelCatalog.providers["qwen-token-plan"].models).toEqual(provider.models); - expect(manifest.modelCatalog.discovery["qwen-token-plan"]).toBe("static"); + expect(manifest.modelCatalog.discovery["qwen-token-plan"]).toBe("refreshable"); }); it("uses region-scoped endpoints with the documented GLM 5.2 window", () => { diff --git a/extensions/sglang/openclaw.plugin.json b/extensions/sglang/openclaw.plugin.json index 9646539f5d91..39376e2d0add 100644 --- a/extensions/sglang/openclaw.plugin.json +++ b/extensions/sglang/openclaw.plugin.json @@ -5,6 +5,11 @@ }, "enabledByDefault": true, "providers": ["sglang"], + "modelCatalog": { + "discovery": { + "sglang": "refreshable" + } + }, "providerRequest": { "providers": { "sglang": { diff --git a/extensions/stepfun/index.ts b/extensions/stepfun/index.ts index 1896824ca07e..541b1a943386 100644 --- a/extensions/stepfun/index.ts +++ b/extensions/stepfun/index.ts @@ -5,6 +5,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 { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { applyStepFunPlanConfig, @@ -88,7 +89,7 @@ function resolveDefaultBaseUrl(surface: StepFunSurface, region: StepFunRegion): return region === "cn" ? STEPFUN_STANDARD_CN_BASE_URL : STEPFUN_STANDARD_INTL_BASE_URL; } -function resolveStepFunCatalog( +async function resolveStepFunCatalog( ctx: ProviderCatalogContext, params: { providerId: string; surface: StepFunSurface }, ) { @@ -107,11 +108,15 @@ function resolveStepFunCatalog( // Keep discovery working for legacy/manual auth profiles that resolved a // key but do not encode region in the profile id. const baseUrl = explicitBaseUrl ?? resolveDefaultBaseUrl(params.surface, region ?? "intl"); + const providerConfig = + params.surface === "plan" ? buildStepFunPlanProvider(baseUrl) : buildStepFunProvider(baseUrl); return { - provider: - params.surface === "plan" - ? { ...buildStepFunPlanProvider(baseUrl), apiKey } - : { ...buildStepFunProvider(baseUrl), apiKey }, + provider: await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: params.providerId, + providerConfig, + apiKey, + discoveryApiKey: auth.discoveryApiKey, + }), }; } @@ -205,6 +210,10 @@ export default definePluginEntry({ surface: "standard", }), }, + staticCatalog: { + order: "paired", + run: async () => ({ provider: buildStepFunProvider() }), + }, }); api.registerProvider({ @@ -248,6 +257,10 @@ export default definePluginEntry({ surface: "plan", }), }, + staticCatalog: { + order: "paired", + run: async () => ({ provider: buildStepFunPlanProvider() }), + }, }); }, }); diff --git a/extensions/stepfun/openclaw.plugin.json b/extensions/stepfun/openclaw.plugin.json index f83fb6f59172..964a535b3922 100644 --- a/extensions/stepfun/openclaw.plugin.json +++ b/extensions/stepfun/openclaw.plugin.json @@ -157,8 +157,8 @@ } }, "discovery": { - "stepfun": "static", - "stepfun-plan": "static" + "stepfun": "refreshable", + "stepfun-plan": "refreshable" } }, "providerAuthChoices": [ diff --git a/extensions/tencent/index.ts b/extensions/tencent/index.ts index 36380bd88b31..23451b366ffe 100644 --- a/extensions/tencent/index.ts +++ b/extensions/tencent/index.ts @@ -1,7 +1,7 @@ // Tencent plugin entrypoint registers its OpenClaw integration. import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; -import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog-shared"; +import { buildOpenAICompatibleProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import { TOKENHUB_MODEL_CATALOG, TOKENHUB_PROVIDER_ID, @@ -65,12 +65,16 @@ export default definePluginEntry({ catalog: { order: "simple", run: (ctx) => - buildSingleProviderApiKeyCatalog({ + buildOpenAICompatibleProviderCatalog({ ctx, providerId: TOKENHUB_PROVIDER_ID, buildProvider: buildTokenHubProvider, }), }, + staticCatalog: { + order: "simple", + run: async () => ({ provider: buildTokenHubProvider() }), + }, augmentModelCatalog: () => buildStaticCatalogEntries(TOKENHUB_PROVIDER_ID, TOKENHUB_MODEL_CATALOG), wrapStreamFn: wrapTencentProviderStream, @@ -106,12 +110,16 @@ export default definePluginEntry({ catalog: { order: "simple", run: (ctx) => - buildSingleProviderApiKeyCatalog({ + buildOpenAICompatibleProviderCatalog({ ctx, providerId: TOKENPLAN_PROVIDER_ID, buildProvider: buildTokenPlanProvider, }), }, + staticCatalog: { + order: "simple", + run: async () => ({ provider: buildTokenPlanProvider() }), + }, augmentModelCatalog: () => buildStaticCatalogEntries(TOKENPLAN_PROVIDER_ID, TOKENPLAN_MODEL_CATALOG), wrapStreamFn: wrapTencentProviderStream, diff --git a/extensions/tencent/openclaw.plugin.json b/extensions/tencent/openclaw.plugin.json index 8d6d02991bda..d6dbf38a0351 100644 --- a/extensions/tencent/openclaw.plugin.json +++ b/extensions/tencent/openclaw.plugin.json @@ -90,8 +90,8 @@ } }, "discovery": { - "tencent-tokenhub": "static", - "tencent-tokenplan": "static" + "tencent-tokenhub": "refreshable", + "tencent-tokenplan": "refreshable" } }, "setup": { diff --git a/extensions/together/index.ts b/extensions/together/index.ts index 445a3750cde8..b951ad6c07e2 100644 --- a/extensions/together/index.ts +++ b/extensions/together/index.ts @@ -31,6 +31,8 @@ export default defineSingleProviderPluginEntry({ ], catalog: { buildProvider: buildTogetherProvider, + buildStaticProvider: buildTogetherProvider, + liveModelDiscovery: true, }, classifyFailoverReason: ({ errorMessage }) => /\bconcurrency limit\b.*\b(?:breached|reached)\b/i.test(errorMessage) diff --git a/extensions/together/openclaw.plugin.json b/extensions/together/openclaw.plugin.json index 253966ec4dbe..2d53930573f8 100644 --- a/extensions/together/openclaw.plugin.json +++ b/extensions/together/openclaw.plugin.json @@ -117,7 +117,7 @@ } }, "discovery": { - "together": "static" + "together": "refreshable" } }, "configSchema": { diff --git a/extensions/vercel-ai-gateway/openclaw.plugin.json b/extensions/vercel-ai-gateway/openclaw.plugin.json index 16b522d9d7bc..dbc04920c815 100644 --- a/extensions/vercel-ai-gateway/openclaw.plugin.json +++ b/extensions/vercel-ai-gateway/openclaw.plugin.json @@ -6,6 +6,11 @@ }, "enabledByDefault": true, "providers": ["vercel-ai-gateway"], + "modelCatalog": { + "discovery": { + "vercel-ai-gateway": "refreshable" + } + }, "modelIdNormalization": { "providers": { "vercel-ai-gateway": { diff --git a/extensions/vllm/openclaw.plugin.json b/extensions/vllm/openclaw.plugin.json index efccb8d069d0..21f9bbd8dcd9 100644 --- a/extensions/vllm/openclaw.plugin.json +++ b/extensions/vllm/openclaw.plugin.json @@ -5,6 +5,11 @@ }, "enabledByDefault": true, "providers": ["vllm"], + "modelCatalog": { + "discovery": { + "vllm": "refreshable" + } + }, "providerRequest": { "providers": { "vllm": { diff --git a/extensions/volcengine/index.ts b/extensions/volcengine/index.ts index 668fe17b5e04..f9a2c435b785 100644 --- a/extensions/volcengine/index.ts +++ b/extensions/volcengine/index.ts @@ -1,6 +1,7 @@ // Volcengine plugin entrypoint registers its OpenClaw integration. import { definePluginEntry } 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 { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard"; import { applyVolcengineToolSchemaCompat } from "./api.js"; import { VOLCENGINE_PROVIDER_CATALOG_ENTRIES } from "./provider-catalog.js"; @@ -49,20 +50,42 @@ export default definePluginEntry({ catalog: { order: "paired", run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; + const auth = ctx.resolveProviderApiKey(PROVIDER_ID); + const apiKey = auth.apiKey; if (!apiKey) { return null; } return { providers: Object.fromEntries( - VOLCENGINE_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [ - id, - { ...buildProvider(), apiKey }, - ]), + await Promise.all( + VOLCENGINE_PROVIDER_CATALOG_ENTRIES.map( + async ({ id, buildProvider }) => + [ + id, + await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: id, + providerConfig: buildProvider(), + apiKey, + discoveryApiKey: auth.discoveryApiKey, + }), + ] as const, + ), + ), ), }; }, }, + staticCatalog: { + order: "paired", + run: async () => ({ + providers: Object.fromEntries( + VOLCENGINE_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [ + id, + buildProvider(), + ]), + ), + }), + }, augmentModelCatalog: () => VOLCENGINE_PROVIDER_CATALOG_ENTRIES.flatMap(({ id: provider, models }) => models.map((entry) => ({ diff --git a/extensions/volcengine/openclaw.plugin.json b/extensions/volcengine/openclaw.plugin.json index d77eaf9f0c8b..1ea5ce791bc2 100644 --- a/extensions/volcengine/openclaw.plugin.json +++ b/extensions/volcengine/openclaw.plugin.json @@ -142,8 +142,8 @@ } }, "discovery": { - "volcengine": "static", - "volcengine-plan": "static" + "volcengine": "refreshable", + "volcengine-plan": "refreshable" } }, "providerAuthChoices": [ diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index eff10cc0913d..92ffb4ad1e1c 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -34,6 +34,9 @@ } }, "modelCatalog": { + "discovery": { + "xai": "refreshable" + }, "suppressions": [ { "provider": "xai", diff --git a/extensions/xiaomi/index.ts b/extensions/xiaomi/index.ts index 8212b82e4fea..376bf9a6b5a2 100644 --- a/extensions/xiaomi/index.ts +++ b/extensions/xiaomi/index.ts @@ -19,6 +19,7 @@ import { upsertAuthProfileWithLock, validateApiKeyInput, } from "openclaw/plugin-sdk/provider-auth-api-key"; +import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import { applyModelCompatPatch, buildProviderReplayFamilyHooks, @@ -85,15 +86,15 @@ function hasConfiguredProviderEntry(ctx: ProviderCatalogContext, providerId: str return Boolean(configuredProvider && typeof configuredProvider === "object"); } -function resolveXiaomiCatalog(params: { +async function resolveXiaomiCatalog(params: { ctx: ProviderCatalogContext; providerId: string; buildProvider: () => ReturnType; requireConfiguredProvider?: boolean; requireBaseUrl?: boolean; }) { - const apiKey = params.ctx.resolveProviderApiKey(params.providerId).apiKey; - if (!apiKey) { + const auth = params.ctx.resolveProviderApiKey(params.providerId); + if (!auth.apiKey) { return null; } if ( @@ -107,11 +108,15 @@ function resolveXiaomiCatalog(params: { return null; } return { - provider: { - ...params.buildProvider(), - ...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}), - apiKey, - }, + provider: await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: params.providerId, + providerConfig: { + ...params.buildProvider(), + ...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}), + }, + apiKey: auth.apiKey, + discoveryApiKey: auth.discoveryApiKey, + }), }; } @@ -376,6 +381,10 @@ export default definePluginEntry({ buildProvider: buildXiaomiProvider, }), }, + staticCatalog: { + order: "simple", + run: async () => ({ provider: buildXiaomiProvider() }), + }, ...XIAOMI_PROVIDER_HOOKS, resolveUsageAuth: async (ctx) => { const apiKey = ctx.resolveApiKeyFromConfigAndStore({ @@ -412,6 +421,10 @@ export default definePluginEntry({ requireBaseUrl: true, }), }, + staticCatalog: { + order: "simple", + run: async () => ({ provider: buildXiaomiTokenPlanProvider() }), + }, ...XIAOMI_PROVIDER_HOOKS, resolveUsageAuth: async (ctx) => { const apiKey = ctx.resolveApiKeyFromConfigAndStore({ diff --git a/extensions/xiaomi/openclaw.plugin.json b/extensions/xiaomi/openclaw.plugin.json index 1044b0b3c095..49e12ce86f6b 100644 --- a/extensions/xiaomi/openclaw.plugin.json +++ b/extensions/xiaomi/openclaw.plugin.json @@ -99,8 +99,8 @@ } }, "discovery": { - "xiaomi": "static", - "xiaomi-token-plan": "runtime" + "xiaomi": "refreshable", + "xiaomi-token-plan": "refreshable" } }, "providerEndpoints": [ diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index e3394bdb5f97..183a88779d28 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -21,6 +21,8 @@ import { upsertAuthProfileWithLock, validateApiKeyInput, } from "openclaw/plugin-sdk/provider-auth-api-key"; +import { buildOpenAICompatibleProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; +import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared"; import { buildProviderReplayFamilyHooks, normalizeModelCompat, @@ -36,6 +38,7 @@ import { detectZaiEndpoint, type ZaiEndpointId } from "./detect.js"; import { zaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { buildZaiModelDefinition, resolveZaiBaseUrl } from "./model-definitions.js"; import { applyZaiConfig, applyZaiProviderConfig, resolveZaiModelId } from "./onboard.js"; +import manifest from "./openclaw.plugin.json" with { type: "json" }; import { isGlm52ModelId, resolveThinkingProfile } from "./provider-policy-api.js"; const PROVIDER_ID = "zai"; @@ -43,6 +46,13 @@ const GLM5_TEMPLATE_MODEL_ID = "glm-4.7"; const PROFILE_ID = "zai:default"; type UpsertAuthProfileParams = Parameters[0]; +function buildZaiCatalogProvider() { + return buildManifestModelProviderConfig({ + providerId: PROVIDER_ID, + catalog: manifest.modelCatalog.providers.zai, + }); +} + function resolveDeprecatedPiAgentAuthPath(env: NodeJS.ProcessEnv): string { const home = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir(); return path.join(home, ".pi", "agent", "auth.json"); @@ -384,6 +394,20 @@ export default definePluginEntry({ endpoint: "cn", }), ], + catalog: { + order: "simple", + run: (ctx) => + buildOpenAICompatibleProviderCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildZaiCatalogProvider, + allowExplicitBaseUrl: true, + }), + }, + staticCatalog: { + order: "simple", + run: async () => ({ provider: buildZaiCatalogProvider() }), + }, resolveDynamicModel: (ctx) => resolveGlm5ForwardCompatModel(ctx), matchesContextOverflowError: ({ errorMessage }) => /\b(?:tokens? in request more than max tokens? allowed|prompt exceeds max(?:imum)? length)\b/i.test( diff --git a/extensions/zai/openclaw.plugin.json b/extensions/zai/openclaw.plugin.json index abd933808fd2..bc4484aeb9a3 100644 --- a/extensions/zai/openclaw.plugin.json +++ b/extensions/zai/openclaw.plugin.json @@ -241,7 +241,7 @@ } }, "discovery": { - "zai": "static" + "zai": "refreshable" } }, "modelPricing": { diff --git a/src/agents/embedded-agent-runner/model.static-catalog.test.ts b/src/agents/embedded-agent-runner/model.static-catalog.test.ts index cbfdc1fce22d..cb5d3898c3a8 100644 --- a/src/agents/embedded-agent-runner/model.static-catalog.test.ts +++ b/src/agents/embedded-agent-runner/model.static-catalog.test.ts @@ -616,8 +616,8 @@ describe("resolveBundledStaticCatalogModel", () => { } }); - it("can include bundled runtime-discovery manifest catalog rows for configured fallbacks", () => { - setManifestPlugins([createMistralManifestPlugin({ discovery: "runtime" })]); + it("can include bundled refreshable manifest catalog rows for configured fallbacks", () => { + setManifestPlugins([createMistralManifestPlugin({ discovery: "refreshable" })]); const model = resolveBundledStaticCatalogModel({ provider: "mistral", diff --git a/src/agents/embedded-agent-runner/model.static-catalog.ts b/src/agents/embedded-agent-runner/model.static-catalog.ts index 79fa55369715..45e76a161dd5 100644 --- a/src/agents/embedded-agent-runner/model.static-catalog.ts +++ b/src/agents/embedded-agent-runner/model.static-catalog.ts @@ -238,7 +238,10 @@ export function createBundledStaticCatalogModelResolver(params?: { for (const entry of plan.entries) { if ( entry.discovery !== "static" && - !(params?.includeRuntimeDiscovery && entry.discovery === "runtime") + !( + params?.includeRuntimeDiscovery && + (entry.discovery === "runtime" || entry.discovery === "refreshable") + ) ) { continue; } diff --git a/src/plugin-sdk/provider-catalog-live-normalize.internal.ts b/src/plugin-sdk/provider-catalog-live-normalize.internal.ts new file mode 100644 index 000000000000..84a471f1590a --- /dev/null +++ b/src/plugin-sdk/provider-catalog-live-normalize.internal.ts @@ -0,0 +1,286 @@ +import type { ModelDefinitionConfig, ModelProviderConfig } from "./provider-model-shared.js"; + +export function readLiveModelCatalogRecord(body: unknown): Record | undefined { + return body && typeof body === "object" && !Array.isArray(body) + ? (body as Record) + : undefined; +} + +function readLiveModelString( + record: Record | undefined, + keys: readonly string[], +): string | undefined { + for (const key of keys) { + const value = record?.[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return undefined; +} + +function readLiveModelBoolean( + record: Record | undefined, + keys: readonly string[], +): boolean | undefined { + for (const key of keys) { + const value = record?.[key]; + if (typeof value === "boolean") { + return value; + } + } + return undefined; +} + +function readLiveModelPositiveInteger( + records: readonly (Record | undefined)[], + keys: readonly string[], +): number | undefined { + for (const record of records) { + for (const key of keys) { + const value = record?.[key]; + if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) { + return value; + } + } + } + return undefined; +} + +function readLiveModelStringArray( + records: readonly (Record | undefined)[], + keys: readonly string[], +): string[] { + for (const record of records) { + for (const key of keys) { + const value = record?.[key]; + if (Array.isArray(value)) { + const strings = value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + if (strings.length > 0) { + return strings; + } + } + } + } + return []; +} + +function isSafeLiveModelId(value: string): boolean { + if (!value || value.length > 512) { + return false; + } + for (const char of value) { + const codePoint = char.codePointAt(0) ?? 0; + if (codePoint <= 0x20 || codePoint === 0x7f) { + return false; + } + } + return true; +} + +const NON_TEXT_MODEL_ID_PATTERN = + /(?:^|[/_:.-])(?:embed(?:ding)?|rerank(?:er)?|whisper|transcri(?:be|ption)|tts|speech|moderation|guard|gpt-image|dall-e|flux|sdxl|stable-diffusion|imagen|image-gen(?:eration)?|text-to-image|veo|sora|video-gen(?:eration)?|text-to-video)(?:$|[/_:.-])/i; + +function rowAdvertisesNonTextModel( + record: Record, + nestedRecords: readonly (Record | undefined)[], +): boolean { + const outputModalities = readLiveModelStringArray( + [record, ...nestedRecords], + ["output_modalities", "outputModalities", "output"], + ); + if (outputModalities.length > 0 && !outputModalities.includes("text")) { + return true; + } + const kind = readLiveModelString(record, [ + "type", + "task", + "model_type", + "modelType", + "pipeline_tag", + ]); + return Boolean(kind && NON_TEXT_MODEL_ID_PATTERN.test(kind)); +} + +function rowAdvertisesChatModel( + record: Record, + nestedRecords: readonly (Record | undefined)[], +): boolean | undefined { + const explicitChatCapability = readLiveModelBoolean(nestedRecords[0], [ + "completion_chat", + "chat_completion", + "chatCompletion", + ]); + if (explicitChatCapability !== undefined) { + return explicitChatCapability; + } + const capabilityStrings = readLiveModelStringArray( + [record, ...nestedRecords], + ["capabilities", "features", "endpoints", "supported_endpoints"], + ); + if ( + capabilityStrings.some((value) => + /(?:^|[./:])(?:chat|responses?|generate|completions?)(?:$|[./:])|(?:^|[./:_-])(?:chat[-_]completions?|completions?[-_]chat|text[-_]generation)(?:$|[./:_-])/.test( + value, + ), + ) + ) { + return true; + } + return undefined; +} + +function commonPrefixLength(left: string, right: string): number { + const limit = Math.min(left.length, right.length); + let index = 0; + while (index < limit && left[index] === right[index]) { + index += 1; + } + return index; +} + +function findLiveModelTemplate( + modelId: string, + models: readonly ModelDefinitionConfig[], +): ModelDefinitionConfig | undefined { + const exact = models.find((model) => model.id === modelId); + if (exact) { + return exact; + } + const normalizedId = modelId.toLowerCase(); + let best: ModelDefinitionConfig | undefined; + let bestScore = 0; + for (const model of models) { + const score = commonPrefixLength(normalizedId, model.id.toLowerCase()); + if (score > bestScore) { + best = model; + bestScore = score; + } + } + return bestScore >= 4 ? best : undefined; +} + +function inferLiveModelReasoning(modelId: string): boolean { + return /(?:^|[/_:.-])(?:reason(?:er|ing)?|thinking|deepseek-r1|o[134](?:-mini)?|gpt-5)(?:$|[/_:.-])/i.test( + modelId, + ); +} + +function buildOpenAICompatibleLiveModel( + row: unknown, + fallback: ModelProviderConfig, +): ModelDefinitionConfig | undefined { + const record = readLiveModelCatalogRecord(row); + const id = readLiveModelString(record, ["id", "model", "model_name", "modelName"]); + if (!record || !id || !isSafeLiveModelId(id)) { + return undefined; + } + if (readLiveModelBoolean(record, ["active", "enabled", "available"]) === false) { + return undefined; + } + if (readLiveModelBoolean(record, ["archived", "deprecated"]) === true) { + return undefined; + } + const capabilities = readLiveModelCatalogRecord(record.capabilities); + const architecture = readLiveModelCatalogRecord(record.architecture); + const topProvider = readLiveModelCatalogRecord(record.top_provider); + const modelInfo = readLiveModelCatalogRecord(record.model_info); + const nestedRecords = [capabilities, architecture, topProvider, modelInfo]; + const advertisedChatCapability = rowAdvertisesChatModel(record, nestedRecords); + if ( + advertisedChatCapability === false || + (advertisedChatCapability !== true && + (rowAdvertisesNonTextModel(record, nestedRecords) || NON_TEXT_MODEL_ID_PATTERN.test(id))) + ) { + return undefined; + } + + const exact = fallback.models.find((model) => model.id === id); + if (exact) { + return exact; + } + const template = findLiveModelTemplate(id, fallback.models); + const inputModalities = readLiveModelStringArray( + [record, architecture, capabilities, modelInfo], + ["input_modalities", "inputModalities", "input"], + ); + const contextWindow = + readLiveModelPositiveInteger( + [record, topProvider, capabilities, modelInfo], + [ + "context_window", + "contextWindow", + "context_length", + "contextLength", + "context_size", + "contextSize", + "max_context_length", + "maxModelLen", + "max_model_len", + ], + ) ?? + fallback.contextWindow ?? + template?.contextWindow ?? + 128_000; + const maxTokens = + readLiveModelPositiveInteger( + [record, topProvider, capabilities, modelInfo], + [ + "max_completion_tokens", + "maxCompletionTokens", + "max_output_tokens", + "maxOutputTokens", + "output_token_limit", + "outputTokenLimit", + ], + ) ?? + fallback.maxTokens ?? + template?.maxTokens ?? + Math.min(contextWindow, 8192); + const explicitReasoning = readLiveModelBoolean(record, [ + "reasoning", + "supports_reasoning", + "supportsReasoning", + "thinking", + ]); + const featureNames = readLiveModelStringArray( + [record, capabilities, modelInfo], + ["features", "supported_parameters", "supportedParameters"], + ); + const reasoning = + explicitReasoning ?? + (featureNames.some((feature) => /reason|think/.test(feature)) || + template?.reasoning === true || + inferLiveModelReasoning(id)); + const input: ModelDefinitionConfig["input"] = inputModalities.includes("image") + ? ["text", "image"] + : (template?.input ?? ["text"]); + + return { + id, + name: readLiveModelString(record, ["display_name", "displayName", "name"]) ?? id, + ...(template?.api ? { api: template.api } : {}), + reasoning, + input, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens, + ...(template?.compat ? { compat: template.compat } : {}), + ...(template?.thinkingLevelMap ? { thinkingLevelMap: template.thinkingLevelMap } : {}), + }; +} + +export function buildOpenAICompatibleLiveModels( + rows: readonly unknown[], + fallback: ModelProviderConfig, +): ModelDefinitionConfig[] { + const models = rows + .map((row) => buildOpenAICompatibleLiveModel(row, fallback)) + .filter((model): model is ModelDefinitionConfig => Boolean(model)); + return [...new Map(models.map((model) => [model.id, model])).values()].toSorted((a, b) => + a.id.localeCompare(b.id), + ); +} diff --git a/src/plugin-sdk/provider-catalog-live-runtime.test.ts b/src/plugin-sdk/provider-catalog-live-runtime.test.ts index bfffbb177577..4a8fbe15b8ed 100644 --- a/src/plugin-sdk/provider-catalog-live-runtime.test.ts +++ b/src/plugin-sdk/provider-catalog-live-runtime.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type MockedFunction } import { NON_ENV_SECRETREF_MARKER } from "./provider-auth-runtime.js"; import { buildLiveModelProviderConfig, + buildOpenAICompatibleLiveModelProviderConfig, clearLiveCatalogCacheForTests, fetchLiveProviderModelIds, getCachedLiveProviderModelRows, @@ -187,6 +188,42 @@ describe("provider-catalog-live-runtime", () => { expect(release).toHaveBeenCalledTimes(2); }); + it("follows Anthropic-style last_id pagination", async () => { + const release = vi.fn(async () => undefined); + const fetchGuardMock: MockedFunction = vi + .fn() + .mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + data: [{ id: "model-a", object: "model" }], + has_more: true, + last_id: "model-a", + }), + ), + finalUrl: "https://provider.example.test/v1/models", + release, + }) + .mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ data: [{ id: "model-b", object: "model" }], has_more: false }), + ), + finalUrl: "https://provider.example.test/v1/models?after_id=model-a", + release, + }); + + await expect( + fetchLiveProviderModelIds({ + providerId: "provider", + endpoint: "https://provider.example.test/v1/models", + fetchGuard: fetchGuardMock, + }), + ).resolves.toEqual(["model-a", "model-b"]); + + expect(fetchGuardMock.mock.calls[1]?.[0].url).toBe( + "https://provider.example.test/v1/models?after_id=model-a", + ); + }); + it("follows absolute next links when providers return them", async () => { const release = vi.fn(async () => undefined); const fetchGuardMock: MockedFunction = vi @@ -367,6 +404,39 @@ describe("provider-catalog-live-runtime", () => { ); }); + it("follows next_page_token pagination with the matching query parameter", async () => { + const release = vi.fn(async () => undefined); + const fetchGuardMock: MockedFunction = vi + .fn() + .mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + data: [{ id: "model-a", object: "model" }], + next_page_token: "page-2", + }), + ), + finalUrl: "https://provider.example.test/v1/models?page_size=1000", + release, + }) + .mockResolvedValueOnce({ + response: new Response(JSON.stringify({ data: [{ id: "model-b", object: "model" }] })), + finalUrl: "https://provider.example.test/v1/models?page_size=1000&page_token=page-2", + release, + }); + + await expect( + fetchLiveProviderModelIds({ + providerId: "provider", + endpoint: "https://provider.example.test/v1/models?page_size=1000", + fetchGuard: fetchGuardMock, + }), + ).resolves.toEqual(["model-a", "model-b"]); + + expect(fetchGuardMock.mock.calls[1]?.[0].url).toBe( + "https://provider.example.test/v1/models?page_size=1000&page_token=page-2", + ); + }); + it("fails truncated live catalog pagination instead of returning partial rows", async () => { const release = vi.fn(async () => undefined); const fetchGuardMock: MockedFunction = vi.fn(async ({ url }) => { @@ -700,6 +770,145 @@ describe("provider-catalog-live-runtime", () => { expect(fetchGuardMock).toHaveBeenCalledTimes(2); }); + it("builds newly listed text models from OpenAI-compatible catalog metadata", async () => { + const { fetchGuard, fetchGuardMock } = buildFetchGuard({ + data: [ + { + id: "chat-v2", + object: "model", + active: true, + context_window: 262_144, + max_completion_tokens: 32_768, + input_modalities: ["text", "image"], + features: ["reasoning"], + }, + { id: "text-embedding-4", object: "model" }, + { id: "gpt-image-2-oai", object: "model" }, + { id: "retired-chat", object: "model", active: false }, + { id: "archived-chat", object: "model", archived: true }, + { id: "deprecated-chat", object: "model", deprecated: true }, + { + id: "fim-only", + object: "model", + capabilities: { completion_chat: false, completion_fim: true }, + }, + { id: "image-generation-v2", object: "model", features: ["image_generation"] }, + { + id: "chat-and-image-v2", + object: "model", + capabilities: { completion_chat: true }, + features: ["image_generation"], + }, + { + id: "image-only", + object: "model", + output_modalities: ["image"], + }, + ], + }); + + const provider = await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: "provider", + providerConfig: { + api: "openai-completions", + baseUrl: "https://provider.example.test/v1/", + models: [buildModel("chat-v1")], + }, + apiKey: "provider-key", + fetchGuard, + }); + + expect(provider.models).toEqual([ + expect.objectContaining({ id: "chat-and-image-v2" }), + expect.objectContaining({ + id: "chat-v2", + reasoning: true, + input: ["text", "image"], + contextWindow: 262_144, + maxTokens: 32_768, + }), + ]); + expect(fetchGuardMock.mock.calls[0]?.[0].url).toBe("https://provider.example.test/v1/models"); + const headers = fetchGuardMock.mock.calls[0]?.[0].init?.headers; + expect(headers).toBeInstanceOf(Headers); + expect((headers as Headers).get("authorization")).toBe("Bearer provider-key"); + }); + + it("keeps trusted static metadata for live ids already in the provider seed", async () => { + const { fetchGuard } = buildFetchGuard({ + data: [{ id: "chat-v1", object: "model", context_window: 1 }], + }); + const seed = buildModel("chat-v1"); + + const provider = await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: "provider", + providerConfig: { + api: "openai-completions", + baseUrl: "https://provider.example.test/v1", + models: [seed], + }, + fetchGuard, + }); + + expect(provider.models).toEqual([seed]); + }); + + it("supports provider-specific model-list paths and headers", async () => { + const { fetchGuard, fetchGuardMock } = buildFetchGuard({ + data: [{ id: "claude-next", object: "model" }], + }); + + await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: "anthropic-style", + providerConfig: { + api: "anthropic-messages", + baseUrl: "https://provider.example.test", + models: [buildModel("claude-current")], + }, + apiKey: "provider-key", + modelDiscovery: { + endpointPath: "v1/models", + buildRequestHeaders: ({ apiKey }) => ({ + "anthropic-version": "2023-06-01", + ...(apiKey ? { "x-api-key": apiKey } : {}), + }), + }, + fetchGuard, + }); + + expect(fetchGuardMock.mock.calls[0]?.[0].url).toBe("https://provider.example.test/v1/models"); + const headers = fetchGuardMock.mock.calls[0]?.[0].init?.headers; + expect(headers).toBeInstanceOf(Headers); + expect((headers as Headers).get("x-api-key")).toBe("provider-key"); + expect((headers as Headers).get("anthropic-version")).toBe("2023-06-01"); + }); + + it("does not send credentials to a fixed discovery endpoint after a base URL override", async () => { + const fetchGuardMock: MockedFunction = vi.fn(); + const providerConfig = { + api: "openai-completions" as const, + baseUrl: "https://private-proxy.example.test/v1", + models: [buildModel("chat-current")], + }; + + await expect( + buildOpenAICompatibleLiveModelProviderConfig({ + providerId: "provider", + providerConfig, + apiKey: "private-proxy-key", + modelDiscovery: { + endpointUrl: { + url: "https://provider.example.test/v1/models", + requireBaseUrl: "https://provider.example.test/v1", + }, + }, + fetchGuard: fetchGuardMock, + }), + ).resolves.toEqual({ ...providerConfig, apiKey: "private-proxy-key" }); + + expect(fetchGuardMock).not.toHaveBeenCalled(); + }); + it("reports incomplete pagination on malformed absolute next URL with no usable fallback", async () => { const release = vi.fn(async () => undefined); const fetchGuardMock: MockedFunction = vi.fn(async () => ({ diff --git a/src/plugin-sdk/provider-catalog-live-runtime.ts b/src/plugin-sdk/provider-catalog-live-runtime.ts index e6d9dca5d998..081cbf1b4459 100644 --- a/src/plugin-sdk/provider-catalog-live-runtime.ts +++ b/src/plugin-sdk/provider-catalog-live-runtime.ts @@ -1,7 +1,13 @@ import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; import { readResponseWithLimit } from "../infra/http-body.js"; import { retainSafeHeadersForCrossOriginRedirect } from "../infra/net/redirect-headers.js"; +import type { ProviderCatalogContext, ProviderCatalogResult } from "../plugins/types.js"; import { + buildOpenAICompatibleLiveModels, + readLiveModelCatalogRecord, +} from "./provider-catalog-live-normalize.internal.js"; +import { + buildSingleProviderApiKeyCatalog, clearLiveCatalogCacheForTests, getCachedLiveCatalogValue, } from "./provider-catalog-shared.js"; @@ -75,6 +81,28 @@ export type BuildLiveModelProviderConfigParams cacheKeyParts?: readonly unknown[]; }; +export type OpenAICompatibleModelDiscoveryOptions = { + /** Fixed endpoint used only while the effective inference base remains canonical. */ + endpointUrl?: { + url: string; + requireBaseUrl: string; + }; + /** Relative path appended to the effective provider base URL. Defaults to `models`. */ + endpointPath?: string; + /** Provider-specific response row selector when the response is not `{ data: [] }`. */ + readRows?: FetchLiveProviderModelRowsParams["readRows"]; + /** Provider-specific authorization headers for non-Bearer model-list APIs. */ + buildRequestHeaders?: FetchLiveProviderModelRowsParams["buildRequestHeaders"]; +}; + +export type BuildOpenAICompatibleProviderCatalogParams = { + ctx: ProviderCatalogContext; + providerId: string; + buildProvider: () => ModelProviderConfig | Promise; + allowExplicitBaseUrl?: boolean; + modelDiscovery?: OpenAICompatibleModelDiscoveryOptions; +}; + function readDefaultLiveModelCatalogRows(body: unknown): readonly unknown[] { if (Array.isArray(body)) { return body; @@ -164,12 +192,6 @@ function readLiveModelCatalogString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } -function readLiveModelCatalogRecord(body: unknown): Record | undefined { - return body && typeof body === "object" && !Array.isArray(body) - ? (body as Record) - : undefined; -} - function readLiveModelCatalogNextUrl(body: unknown): string | undefined { const record = readLiveModelCatalogRecord(body); if (!record) { @@ -181,7 +203,7 @@ function readLiveModelCatalogNextUrl(body: unknown): string | undefined { function readLiveModelCatalogCursor( body: unknown, -): { name: "after" | "pageToken"; value: string } | undefined { +): { name: "after" | "after_id" | "pageToken" | "page_token"; value: string } | undefined { const record = readLiveModelCatalogRecord(body); if (!record || record.has_more === false) { return undefined; @@ -190,8 +212,17 @@ function readLiveModelCatalogCursor( 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); - return nextPageToken ? { name: "pageToken", value: nextPageToken } : undefined; + if (nextPageToken) { + return { name: "pageToken", value: nextPageToken }; + } + const nextPageTokenSnakeCase = readLiveModelCatalogString(record.next_page_token); + return nextPageTokenSnakeCase ? { name: "page_token", value: nextPageTokenSnakeCase } : undefined; } type LiveModelCatalogNextPageResolution = @@ -208,7 +239,8 @@ function bodyAdvertisesMoreLiveModelCatalogPages(body: unknown): boolean { record.has_more === true || readLiveModelCatalogNextUrl(body) || readLiveModelCatalogString(record.next_cursor) || - readLiveModelCatalogString(record.nextPageToken), + readLiveModelCatalogString(record.nextPageToken) || + readLiveModelCatalogString(record.next_page_token), ); } @@ -430,3 +462,90 @@ export async function buildLiveModelProviderConfig, +): string | undefined { + const effectiveBaseUrl = baseUrl.trim().replace(/\/+$/, ""); + const requiredBaseUrl = endpoint.requireBaseUrl.trim().replace(/\/+$/, ""); + return effectiveBaseUrl === requiredBaseUrl ? endpoint.url : undefined; +} + +export async function buildOpenAICompatibleLiveModelProviderConfig(params: { + providerId: string; + providerConfig: ModelProviderConfig; + apiKey?: string; + discoveryApiKey?: string; + modelDiscovery?: OpenAICompatibleModelDiscoveryOptions; + fetchGuard?: LiveModelCatalogFetchGuard; + signal?: AbortSignal; +}): Promise { + const fallback = { + ...params.providerConfig, + ...(params.apiKey ? { apiKey: params.apiKey } : {}), + }; + const endpoint = params.modelDiscovery?.endpointUrl + ? resolveFixedLiveModelDiscoveryEndpoint(fallback.baseUrl, params.modelDiscovery.endpointUrl) + : resolveLiveModelDiscoveryEndpoint( + fallback.baseUrl, + params.modelDiscovery?.endpointPath ?? "models", + ); + if (!endpoint) { + return fallback; + } + try { + const rows = await getCachedLiveProviderModelRows({ + providerId: params.providerId, + endpoint, + apiKey: params.apiKey, + discoveryApiKey: params.discoveryApiKey, + fetchGuard: params.fetchGuard, + signal: params.signal, + ttlMs: 60_000, + auditContext: `${params.providerId}-model-discovery`, + readRows: params.modelDiscovery?.readRows, + buildRequestHeaders: params.modelDiscovery?.buildRequestHeaders, + shouldCacheRows: (modelRows) => + buildOpenAICompatibleLiveModels(modelRows, fallback).length > 0, + }); + const models = buildOpenAICompatibleLiveModels(rows, fallback); + if (models.length > 0) { + return { ...fallback, models }; + } + } catch { + // Provider catalogs are advisory. Preserve the provider-owned seed when + // credentials, networking, or a vendor response prevents live discovery. + } + return fallback; +} + +export async function buildOpenAICompatibleProviderCatalog( + params: BuildOpenAICompatibleProviderCatalogParams, +): Promise { + const result = await buildSingleProviderApiKeyCatalog({ + ctx: params.ctx, + providerId: params.providerId, + buildProvider: params.buildProvider, + allowExplicitBaseUrl: params.allowExplicitBaseUrl, + }); + 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, + }), + }; +} diff --git a/src/plugin-sdk/provider-entry.ts b/src/plugin-sdk/provider-entry.ts index bc0597d84513..af3bf761e230 100644 --- a/src/plugin-sdk/provider-entry.ts +++ b/src/plugin-sdk/provider-entry.ts @@ -26,6 +26,10 @@ import type { OpenClawPluginConfigSchema, OpenClawPluginDefinition, } from "./plugin-entry.js"; +import { + buildOpenAICompatibleProviderCatalog, + type OpenAICompatibleModelDiscoveryOptions, +} from "./provider-catalog-live-runtime.js"; import { buildSingleProviderApiKeyCatalog } from "./provider-catalog-shared.js"; type ApiKeyAuthMethodOptions = Parameters[0]; @@ -66,6 +70,10 @@ export type SingleProviderPluginCatalogOptions = * Allows operator-configured base URLs to override the provider catalog base URL. */ allowExplicitBaseUrl?: boolean; + /** + * Discovers text/chat models from the provider's OpenAI-compatible model-list endpoint. + */ + liveModelDiscovery?: true | OpenAICompatibleModelDiscoveryOptions; run?: never; order?: never; staticRun?: never; @@ -86,6 +94,7 @@ export type SingleProviderPluginCatalogOptions = buildProvider?: never; buildStaticProvider?: never; allowExplicitBaseUrl?: never; + liveModelDiscovery?: never; }; /** @@ -276,12 +285,26 @@ export function defineSingleProviderPluginEntry(options: SingleProviderPluginOpt catalog = { order: "simple", run: (ctx: ProviderCatalogContext): Promise => - buildSingleProviderApiKeyCatalog({ - ctx, - providerId, - buildProvider, - ...(provider.catalog.allowExplicitBaseUrl ? { allowExplicitBaseUrl: true } : {}), - }), + provider.catalog.liveModelDiscovery + ? buildOpenAICompatibleProviderCatalog({ + ctx, + providerId, + buildProvider, + ...(provider.catalog.allowExplicitBaseUrl + ? { allowExplicitBaseUrl: true } + : {}), + ...(provider.catalog.liveModelDiscovery === true + ? {} + : { modelDiscovery: provider.catalog.liveModelDiscovery }), + }) + : buildSingleProviderApiKeyCatalog({ + ctx, + providerId, + buildProvider, + ...(provider.catalog.allowExplicitBaseUrl + ? { allowExplicitBaseUrl: true } + : {}), + }), }; } const staticCatalog: ProviderPluginCatalog | undefined =