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
This commit is contained in:
Jason (Json)
2026-07-21 19:29:57 -06:00
committed by GitHub
parent 0f066eec81
commit 55cf9523e0
88 changed files with 2031 additions and 144 deletions
+55 -2
View File
@@ -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 <token>`; 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
+7
View File
@@ -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.
</Tip>
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.
</Tab>
<Tab title="Gemini CLI (OAuth)">
@@ -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"
}
}
}
@@ -7,6 +7,11 @@
},
"enabledByDefault": true,
"providers": ["amazon-bedrock"],
"modelCatalog": {
"discovery": {
"amazon-bedrock": "refreshable"
}
},
"contracts": {
"memoryEmbeddingProviders": ["bedrock"]
},
+1 -1
View File
@@ -213,7 +213,7 @@
},
"discovery": {
"claude-cli": "static",
"anthropic": "static"
"anthropic": "refreshable"
}
},
"modelSupport": {
+1 -1
View File
@@ -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"]) {
+33
View File
@@ -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 }),
+14 -3
View File
@@ -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,
+5
View File
@@ -5,6 +5,11 @@
},
"enabledByDefault": true,
"providers": ["arcee"],
"modelCatalog": {
"discovery": {
"arcee": "runtime"
}
},
"setup": {
"providers": [
{
+25 -5
View File
@@ -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) => ({
+2 -2
View File
@@ -113,8 +113,8 @@
}
},
"discovery": {
"byteplus": "static",
"byteplus-plan": "static"
"byteplus": "refreshable",
"byteplus-plan": "refreshable"
}
},
"providerAuthChoices": [
+1
View File
@@ -39,6 +39,7 @@ export default defineSingleProviderPluginEntry({
catalog: {
buildProvider: buildCerebrasProvider,
buildStaticProvider: buildCerebrasProvider,
liveModelDiscovery: true,
},
},
});
+1 -1
View File
@@ -56,7 +56,7 @@
}
},
"discovery": {
"cerebras": "static"
"cerebras": "refreshable"
}
},
"setup": {
+37 -1
View File
@@ -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",
+2 -1
View File
@@ -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),
+1 -1
View File
@@ -151,7 +151,7 @@
}
},
"discovery": {
"cohere": "static"
"cohere": "refreshable"
}
},
"setup": {
+25
View File
@@ -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<string, unknown>;
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,
+2
View File
@@ -40,6 +40,8 @@ export default defineSingleProviderPluginEntry({
],
catalog: {
buildProvider: buildDeepSeekProvider,
buildStaticProvider: buildDeepSeekProvider,
liveModelDiscovery: true,
},
augmentModelCatalog: ({ config }) =>
readConfiguredProviderCatalogEntries({
+1 -1
View File
@@ -107,7 +107,7 @@
}
},
"discovery": {
"deepseek": "static"
"deepseek": "refreshable"
}
},
"setup": {
+8
View File
@@ -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({
+1 -1
View File
@@ -72,7 +72,7 @@
}
},
"discovery": {
"featherless": "static"
"featherless": "refreshable"
}
},
"configSchema": {
+2
View File
@@ -95,7 +95,9 @@ export default defineSingleProviderPluginEntry({
],
catalog: {
buildProvider: buildFireworksProvider,
buildStaticProvider: buildFireworksProvider,
allowExplicitBaseUrl: true,
liveModelDiscovery: true,
},
...buildProviderReplayFamilyHooks({ family: "openai-compatible" }),
wrapStreamFn: wrapFireworksProviderStream,
+1 -1
View File
@@ -70,7 +70,7 @@
}
},
"discovery": {
"fireworks": "static"
"fireworks": "refreshable"
}
},
"configSchema": {
+1
View File
@@ -35,6 +35,7 @@ export default defineSingleProviderPluginEntry({
buildProvider: buildGmiProvider,
buildStaticProvider: buildGmiProvider,
allowExplicitBaseUrl: true,
liveModelDiscovery: true,
},
augmentModelCatalog: ({ config }) =>
readConfiguredProviderCatalogEntries({
+3
View File
@@ -160,6 +160,9 @@
}
]
}
},
"discovery": {
"gmi": "refreshable"
}
}
}
+40 -1
View File
@@ -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");
+3
View File
@@ -46,6 +46,9 @@
}
},
"modelCatalog": {
"discovery": {
"google": "runtime"
},
"suppressions": [
{
"provider": "google",
+147 -3
View File
@@ -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),
);
});
});
+138
View File
@@ -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<string, unknown>, key: string): string | undefined {
const value = row[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readPositiveInteger(row: Record<string, unknown>, 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<string, unknown>;
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<ModelProviderConfig> {
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,
+52 -1
View File
@@ -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();
}
});
});
+30 -18
View File
@@ -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<ProviderRuntimeModel> | 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,
@@ -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({
+23
View File
@@ -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,
+1 -1
View File
@@ -182,7 +182,7 @@
}
},
"discovery": {
"groq": "static"
"groq": "refreshable"
}
},
"contracts": {
@@ -6,6 +6,11 @@
},
"enabledByDefault": true,
"providers": ["huggingface"],
"modelCatalog": {
"discovery": {
"huggingface": "refreshable"
}
},
"modelIdNormalization": {
"providers": {
"huggingface": {
+7 -2
View File
@@ -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());
},
+5
View File
@@ -5,6 +5,11 @@
},
"enabledByDefault": true,
"providers": ["litellm"],
"modelCatalog": {
"discovery": {
"litellm": "runtime"
}
},
"setup": {
"providers": [
{
+5
View File
@@ -6,6 +6,11 @@
},
"enabledByDefault": true,
"providers": ["lmstudio"],
"modelCatalog": {
"discovery": {
"lmstudio": "refreshable"
}
},
"providerRequest": {
"providers": {
"lmstudio": {
+2
View File
@@ -42,6 +42,8 @@ export default defineSingleProviderPluginEntry({
],
catalog: {
buildProvider: buildLongCatProvider,
buildStaticProvider: buildLongCatProvider,
liveModelDiscovery: true,
},
...buildProviderReplayFamilyHooks({
family: "openai-compatible",
+1 -1
View File
@@ -48,7 +48,7 @@
}
},
"discovery": {
"longcat": "static"
"longcat": "refreshable"
}
},
"setup": {
+1
View File
@@ -39,6 +39,7 @@ export default defineSingleProviderPluginEntry({
catalog: {
buildProvider: buildMetaProvider,
buildStaticProvider: buildMetaProvider,
liveModelDiscovery: true,
},
...buildProviderReplayFamilyHooks({ family: "openai-compatible" }),
wrapStreamFn: wrapMetaProviderStream,
+1 -1
View File
@@ -61,7 +61,7 @@
}
},
"discovery": {
"meta": "static"
"meta": "refreshable"
}
},
"setup": {
+124
View File
@@ -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"),
+6
View File
@@ -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",
+20
View File
@@ -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) {
+33 -20
View File
@@ -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"),
}),
};
}
+2
View File
@@ -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),
+1 -1
View File
@@ -150,7 +150,7 @@
}
},
"discovery": {
"mistral": "static"
"mistral": "refreshable"
}
},
"setup": {
+1
View File
@@ -58,6 +58,7 @@ export default defineSingleProviderPluginEntry({
buildProvider: buildMoonshotProvider,
buildStaticProvider: buildMoonshotProvider,
allowExplicitBaseUrl: true,
liveModelDiscovery: true,
},
applyNativeStreamingUsageCompat: ({ providerConfig }) =>
applyMoonshotNativeStreamingUsageCompat(providerConfig),
+1 -1
View File
@@ -135,7 +135,7 @@
}
},
"discovery": {
"moonshot": "static"
"moonshot": "refreshable"
}
},
"setup": {
+1
View File
@@ -35,6 +35,7 @@ export default defineSingleProviderPluginEntry({
buildProvider: buildNovitaProvider,
buildStaticProvider: buildNovitaProvider,
allowExplicitBaseUrl: true,
liveModelDiscovery: true,
},
augmentModelCatalog: ({ config }) =>
readConfiguredProviderCatalogEntries({
+3
View File
@@ -158,6 +158,9 @@
}
]
}
},
"discovery": {
"novita": "refreshable"
}
}
}
+1 -1
View File
@@ -237,7 +237,7 @@
}
},
"discovery": {
"nvidia": "static"
"nvidia": "refreshable"
}
},
"setup": {
+1
View File
@@ -156,6 +156,7 @@
}
},
"discovery": {
"ollama": "refreshable",
"ollama-cloud": "refreshable"
}
},
+16 -2
View File
@@ -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");
+6
View File
@@ -92,6 +92,8 @@ const MODEL_COSTS: Record<string, ModelDefinitionConfig["cost"]> = {
],
},
"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<string, string> = {
"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",
+7
View File
@@ -57,6 +57,9 @@ function createOpenRouterDoneStreamWithoutGeneration() {
}
type OpenRouterManifest = {
modelCatalog?: {
discovery?: Record<string, string>;
};
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,
+6 -4
View File
@@ -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,
}),
};
},
},
@@ -6,6 +6,11 @@
},
"enabledByDefault": true,
"providers": ["openrouter"],
"modelCatalog": {
"discovery": {
"openrouter": "runtime"
}
},
"modelIdNormalization": {
"providers": {
"openrouter": {
@@ -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);
});
});
+143 -1
View File
@@ -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<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function readString(record: Record<string, unknown> | undefined, key: string): string | undefined {
const value = record?.[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readPositiveInteger(
record: Record<string, unknown> | undefined,
key: string,
): number | undefined {
const value = record?.[key];
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
}
function readStringArray(record: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<ModelProviderConfig> {
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;
}
}
+2
View File
@@ -27,6 +27,8 @@ export default defineSingleProviderPluginEntry({
],
catalog: {
buildProvider: buildQianfanProvider,
buildStaticProvider: buildQianfanProvider,
liveModelDiscovery: true,
},
},
});
+1 -1
View File
@@ -53,7 +53,7 @@
}
},
"discovery": {
"qianfan": "static"
"qianfan": "refreshable"
}
},
"providerAuthChoices": [
+18 -12
View File
@@ -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,
}),
};
},
},
+2 -1
View File
@@ -222,7 +222,8 @@
}
},
"discovery": {
"qwen-token-plan": "static"
"qwen": "runtime",
"qwen-token-plan": "refreshable"
}
},
"contracts": {
+1 -1
View File
@@ -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", () => {
+5
View File
@@ -5,6 +5,11 @@
},
"enabledByDefault": true,
"providers": ["sglang"],
"modelCatalog": {
"discovery": {
"sglang": "refreshable"
}
},
"providerRequest": {
"providers": {
"sglang": {
+18 -5
View File
@@ -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() }),
},
});
},
});
+2 -2
View File
@@ -157,8 +157,8 @@
}
},
"discovery": {
"stepfun": "static",
"stepfun-plan": "static"
"stepfun": "refreshable",
"stepfun-plan": "refreshable"
}
},
"providerAuthChoices": [
+11 -3
View File
@@ -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,
+2 -2
View File
@@ -90,8 +90,8 @@
}
},
"discovery": {
"tencent-tokenhub": "static",
"tencent-tokenplan": "static"
"tencent-tokenhub": "refreshable",
"tencent-tokenplan": "refreshable"
}
},
"setup": {
+2
View File
@@ -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)
+1 -1
View File
@@ -117,7 +117,7 @@
}
},
"discovery": {
"together": "static"
"together": "refreshable"
}
},
"configSchema": {
@@ -6,6 +6,11 @@
},
"enabledByDefault": true,
"providers": ["vercel-ai-gateway"],
"modelCatalog": {
"discovery": {
"vercel-ai-gateway": "refreshable"
}
},
"modelIdNormalization": {
"providers": {
"vercel-ai-gateway": {
+5
View File
@@ -5,6 +5,11 @@
},
"enabledByDefault": true,
"providers": ["vllm"],
"modelCatalog": {
"discovery": {
"vllm": "refreshable"
}
},
"providerRequest": {
"providers": {
"vllm": {
+28 -5
View File
@@ -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) => ({
+2 -2
View File
@@ -142,8 +142,8 @@
}
},
"discovery": {
"volcengine": "static",
"volcengine-plan": "static"
"volcengine": "refreshable",
"volcengine-plan": "refreshable"
}
},
"providerAuthChoices": [
+3
View File
@@ -34,6 +34,9 @@
}
},
"modelCatalog": {
"discovery": {
"xai": "refreshable"
},
"suppressions": [
{
"provider": "xai",
+21 -8
View File
@@ -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<typeof buildXiaomiProvider>;
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({
+2 -2
View File
@@ -99,8 +99,8 @@
}
},
"discovery": {
"xiaomi": "static",
"xiaomi-token-plan": "runtime"
"xiaomi": "refreshable",
"xiaomi-token-plan": "refreshable"
}
},
"providerEndpoints": [
+24
View File
@@ -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<typeof upsertAuthProfileWithLock>[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(
+1 -1
View File
@@ -241,7 +241,7 @@
}
},
"discovery": {
"zai": "static"
"zai": "refreshable"
}
},
"modelPricing": {
@@ -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",
@@ -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;
}
@@ -0,0 +1,286 @@
import type { ModelDefinitionConfig, ModelProviderConfig } from "./provider-model-shared.js";
export function readLiveModelCatalogRecord(body: unknown): Record<string, unknown> | undefined {
return body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>)
: undefined;
}
function readLiveModelString(
record: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown>,
nestedRecords: readonly (Record<string, unknown> | 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<string, unknown>,
nestedRecords: readonly (Record<string, unknown> | 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),
);
}
@@ -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<LiveModelCatalogFetchGuard> = 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<LiveModelCatalogFetchGuard> = 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<LiveModelCatalogFetchGuard> = 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<LiveModelCatalogFetchGuard> = 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<LiveModelCatalogFetchGuard> = 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<LiveModelCatalogFetchGuard> = vi.fn(async () => ({
+128 -9
View File
@@ -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<T extends ModelDefinitionConfig>
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<ModelProviderConfig>;
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<string, unknown> | undefined {
return body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>)
: 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<T extends ModelDefinitionConf
}
return buildProviderConfig(params, params.models);
}
function resolveLiveModelDiscoveryEndpoint(baseUrl: string, endpointPath: string): string {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const normalizedPath = endpointPath.trim().replace(/^\/+/, "");
return `${normalizedBaseUrl}/${normalizedPath}`;
}
function resolveFixedLiveModelDiscoveryEndpoint(
baseUrl: string,
endpoint: NonNullable<OpenAICompatibleModelDiscoveryOptions["endpointUrl"]>,
): 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<ModelProviderConfig> {
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<ProviderCatalogResult> {
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,
}),
};
}
+29 -6
View File
@@ -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<typeof createProviderApiKeyAuthMethod>[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<ProviderCatalogResult> =>
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 =