mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -213,7 +213,7 @@
|
||||
},
|
||||
"discovery": {
|
||||
"claude-cli": "static",
|
||||
"anthropic": "static"
|
||||
"anthropic": "refreshable"
|
||||
}
|
||||
},
|
||||
"modelSupport": {
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,6 +5,11 @@
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["arcee"],
|
||||
"modelCatalog": {
|
||||
"discovery": {
|
||||
"arcee": "runtime"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -113,8 +113,8 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"byteplus": "static",
|
||||
"byteplus-plan": "static"
|
||||
"byteplus": "refreshable",
|
||||
"byteplus-plan": "refreshable"
|
||||
}
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineSingleProviderPluginEntry({
|
||||
catalog: {
|
||||
buildProvider: buildCerebrasProvider,
|
||||
buildStaticProvider: buildCerebrasProvider,
|
||||
liveModelDiscovery: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"cerebras": "static"
|
||||
"cerebras": "refreshable"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"cohere": "static"
|
||||
"cohere": "refreshable"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,6 +40,8 @@ export default defineSingleProviderPluginEntry({
|
||||
],
|
||||
catalog: {
|
||||
buildProvider: buildDeepSeekProvider,
|
||||
buildStaticProvider: buildDeepSeekProvider,
|
||||
liveModelDiscovery: true,
|
||||
},
|
||||
augmentModelCatalog: ({ config }) =>
|
||||
readConfiguredProviderCatalogEntries({
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"deepseek": "static"
|
||||
"deepseek": "refreshable"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"featherless": "static"
|
||||
"featherless": "refreshable"
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
|
||||
@@ -95,7 +95,9 @@ export default defineSingleProviderPluginEntry({
|
||||
],
|
||||
catalog: {
|
||||
buildProvider: buildFireworksProvider,
|
||||
buildStaticProvider: buildFireworksProvider,
|
||||
allowExplicitBaseUrl: true,
|
||||
liveModelDiscovery: true,
|
||||
},
|
||||
...buildProviderReplayFamilyHooks({ family: "openai-compatible" }),
|
||||
wrapStreamFn: wrapFireworksProviderStream,
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"fireworks": "static"
|
||||
"fireworks": "refreshable"
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
|
||||
@@ -35,6 +35,7 @@ export default defineSingleProviderPluginEntry({
|
||||
buildProvider: buildGmiProvider,
|
||||
buildStaticProvider: buildGmiProvider,
|
||||
allowExplicitBaseUrl: true,
|
||||
liveModelDiscovery: true,
|
||||
},
|
||||
augmentModelCatalog: ({ config }) =>
|
||||
readConfiguredProviderCatalogEntries({
|
||||
|
||||
@@ -160,6 +160,9 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"gmi": "refreshable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
}
|
||||
},
|
||||
"modelCatalog": {
|
||||
"discovery": {
|
||||
"google": "runtime"
|
||||
},
|
||||
"suppressions": [
|
||||
{
|
||||
"provider": "google",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,6 +5,11 @@
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["litellm"],
|
||||
"modelCatalog": {
|
||||
"discovery": {
|
||||
"litellm": "runtime"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["lmstudio"],
|
||||
"modelCatalog": {
|
||||
"discovery": {
|
||||
"lmstudio": "refreshable"
|
||||
}
|
||||
},
|
||||
"providerRequest": {
|
||||
"providers": {
|
||||
"lmstudio": {
|
||||
|
||||
@@ -42,6 +42,8 @@ export default defineSingleProviderPluginEntry({
|
||||
],
|
||||
catalog: {
|
||||
buildProvider: buildLongCatProvider,
|
||||
buildStaticProvider: buildLongCatProvider,
|
||||
liveModelDiscovery: true,
|
||||
},
|
||||
...buildProviderReplayFamilyHooks({
|
||||
family: "openai-compatible",
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"longcat": "static"
|
||||
"longcat": "refreshable"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineSingleProviderPluginEntry({
|
||||
catalog: {
|
||||
buildProvider: buildMetaProvider,
|
||||
buildStaticProvider: buildMetaProvider,
|
||||
liveModelDiscovery: true,
|
||||
},
|
||||
...buildProviderReplayFamilyHooks({ family: "openai-compatible" }),
|
||||
wrapStreamFn: wrapMetaProviderStream,
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"meta": "static"
|
||||
"meta": "refreshable"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"mistral": "static"
|
||||
"mistral": "refreshable"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
|
||||
@@ -58,6 +58,7 @@ export default defineSingleProviderPluginEntry({
|
||||
buildProvider: buildMoonshotProvider,
|
||||
buildStaticProvider: buildMoonshotProvider,
|
||||
allowExplicitBaseUrl: true,
|
||||
liveModelDiscovery: true,
|
||||
},
|
||||
applyNativeStreamingUsageCompat: ({ providerConfig }) =>
|
||||
applyMoonshotNativeStreamingUsageCompat(providerConfig),
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"moonshot": "static"
|
||||
"moonshot": "refreshable"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
|
||||
@@ -35,6 +35,7 @@ export default defineSingleProviderPluginEntry({
|
||||
buildProvider: buildNovitaProvider,
|
||||
buildStaticProvider: buildNovitaProvider,
|
||||
allowExplicitBaseUrl: true,
|
||||
liveModelDiscovery: true,
|
||||
},
|
||||
augmentModelCatalog: ({ config }) =>
|
||||
readConfiguredProviderCatalogEntries({
|
||||
|
||||
@@ -158,6 +158,9 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"novita": "refreshable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"nvidia": "static"
|
||||
"nvidia": "refreshable"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
|
||||
@@ -156,6 +156,7 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"ollama": "refreshable",
|
||||
"ollama-cloud": "refreshable"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ export default defineSingleProviderPluginEntry({
|
||||
],
|
||||
catalog: {
|
||||
buildProvider: buildQianfanProvider,
|
||||
buildStaticProvider: buildQianfanProvider,
|
||||
liveModelDiscovery: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"qianfan": "static"
|
||||
"qianfan": "refreshable"
|
||||
}
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
|
||||
+18
-12
@@ -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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -222,7 +222,8 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"qwen-token-plan": "static"
|
||||
"qwen": "runtime",
|
||||
"qwen-token-plan": "refreshable"
|
||||
}
|
||||
},
|
||||
"contracts": {
|
||||
|
||||
@@ -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,6 +5,11 @@
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["sglang"],
|
||||
"modelCatalog": {
|
||||
"discovery": {
|
||||
"sglang": "refreshable"
|
||||
}
|
||||
},
|
||||
"providerRequest": {
|
||||
"providers": {
|
||||
"sglang": {
|
||||
|
||||
@@ -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() }),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -157,8 +157,8 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"stepfun": "static",
|
||||
"stepfun-plan": "static"
|
||||
"stepfun": "refreshable",
|
||||
"stepfun-plan": "refreshable"
|
||||
}
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -90,8 +90,8 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"tencent-tokenhub": "static",
|
||||
"tencent-tokenplan": "static"
|
||||
"tencent-tokenhub": "refreshable",
|
||||
"tencent-tokenplan": "refreshable"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,6 +5,11 @@
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["vllm"],
|
||||
"modelCatalog": {
|
||||
"discovery": {
|
||||
"vllm": "refreshable"
|
||||
}
|
||||
},
|
||||
"providerRequest": {
|
||||
"providers": {
|
||||
"vllm": {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -142,8 +142,8 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"volcengine": "static",
|
||||
"volcengine-plan": "static"
|
||||
"volcengine": "refreshable",
|
||||
"volcengine-plan": "refreshable"
|
||||
}
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
}
|
||||
},
|
||||
"modelCatalog": {
|
||||
"discovery": {
|
||||
"xai": "refreshable"
|
||||
},
|
||||
"suppressions": [
|
||||
{
|
||||
"provider": "xai",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -99,8 +99,8 @@
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"xiaomi": "static",
|
||||
"xiaomi-token-plan": "runtime"
|
||||
"xiaomi": "refreshable",
|
||||
"xiaomi-token-plan": "refreshable"
|
||||
}
|
||||
},
|
||||
"providerEndpoints": [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user