diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index a3433511e2f0..b29ca4db8f36 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -263,11 +263,9 @@ catalog, API-key auth, and dynamic model resolution. 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 - putting provider policy in OpenClaw core. + OpenAI-compatible projection, keep only that projection in the plugin. Pass + it as `projectRows`; the shared runtime still owns guarded fetches, + provider-auth headers, cache admission, and static fallback. Use `buildLiveModelProviderConfig` when the live API only tells you which provider-owned static catalog rows are currently available: @@ -318,6 +316,11 @@ catalog, API-key auth, and dynamic model resolution. fetchGuard: params.fetchGuard, ttlMs: 60_000, auditContext: "acme-ai-model-discovery", + projectRows: (rows, fallback) => + rows.flatMap((row) => { + const model = projectAcmeModel(row, fallback); + return model ? [model] : []; + }), }); } @@ -358,37 +361,6 @@ catalog, API-key auth, and dynamic model resolution. }); ``` - Use `getCachedLiveProviderModelRows` when the provider API returns richer - metadata and the plugin needs to project rows into OpenClaw model - definitions itself: - - ```typescript index.ts - import { - getCachedLiveProviderModelRows, - LiveModelCatalogHttpError, - } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; - - async function discoverAcmeModels(apiKey: string) { - try { - const rows = await getCachedLiveProviderModelRows({ - providerId: "acme-ai", - endpoint: "https://api.acme-ai.com/v1/models", - apiKey, - ttlMs: 60_000, - auditContext: "acme-ai-model-discovery", - }); - return rows - .map((row) => projectAcmeModel(row)) - .filter((model) => model !== null); - } catch (error) { - if (error instanceof LiveModelCatalogHttpError) { - return STATIC_MODELS; - } - throw error; - } - } - ``` - `run` should stay auth-gated and return `null` when no usable credential is available. Keep an offline `staticRun` or static fallback so setup, docs, tests, and picker surfaces do not depend on live network access. Use a TTL diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 0a4c9e7ac31a..d2107b4a3306 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -147,7 +147,7 @@ are private-local. | `plugin-sdk/provider-env-vars` | Private-local after July 2026; Provider auth env-var lookup helpers | | `plugin-sdk/provider-auth` | `createProviderApiKeyAuthMethod`, `ensureApiKeyFromOptionEnvOrPrompt`, `upsertAuthProfile`, `upsertApiKeyProfile`, `writeOAuthCredentials`, OpenAI Codex auth-import helpers, deprecated `resolveOpenClawAgentDir` compatibility export | | `plugin-sdk/provider-model-shared` | Private-local after July 2026; `ProviderReplayFamily`, `buildProviderReplayFamilyHooks`, `selectPreferredLocalModelId`, `normalizeModelCompat`, shared replay-policy builders, provider-endpoint helpers, and shared model-id normalization helpers | - | `plugin-sdk/provider-catalog-live-runtime` | Private-local after July 2026; Live provider model catalog helpers for guarded `/models`-style discovery: `buildLiveModelProviderConfig`, `fetchLiveProviderModelRows`, `getCachedLiveProviderModelRows`, `fetchLiveProviderModelIds`, `LiveModelCatalogHttpError`, `clearLiveCatalogCacheForTests`, model-id filtering, TTL cache, and static fallback | + | `plugin-sdk/provider-catalog-live-runtime` | Private-local after July 2026; Live provider model catalog helpers for guarded `/models`-style discovery: `buildLiveModelProviderConfig`, provider-owned `projectRows`, `fetchLiveProviderModelRows`, `getCachedLiveProviderModelRows`, `fetchLiveProviderModelIds`, `LiveModelCatalogHttpError`, `clearLiveCatalogCacheForTests`, TTL cache, and static fallback | | `plugin-sdk/provider-catalog-runtime` | Provider catalog augmentation runtime hook and plugin-provider registry seams for contract tests | | `plugin-sdk/provider-catalog-shared` | Private-local after July 2026; `findCatalogTemplate`, `buildSingleProviderApiKeyCatalog`, `buildManifestModelProviderConfig`, `supportsNativeStreamingUsageCompat`, `applyProviderNativeStreamingUsageCompat` | | `plugin-sdk/provider-http` | Private-local after July 2026; Generic provider HTTP/endpoint capability helpers, provider HTTP errors, and audio transcription multipart form helpers | diff --git a/extensions/baseten/api.ts b/extensions/baseten/api.ts index 9270c2210c10..cdb8cf30d20a 100644 --- a/extensions/baseten/api.ts +++ b/extensions/baseten/api.ts @@ -6,10 +6,8 @@ export { BASETEN_MODEL_CATALOG, buildBasetenModelCompat, buildStaticBasetenModels, - discoverBasetenModels, - projectBasetenLiveModels, resolveBasetenDynamicModel, usesBasetenChatTemplateThinking, } from "./models.js"; export { applyBasetenConfig } from "./onboard.js"; -export { buildBasetenProvider, buildStaticBasetenProvider } from "./provider-catalog.js"; +export { buildStaticBasetenProvider } from "./provider-catalog.js"; diff --git a/extensions/baseten/index.ts b/extensions/baseten/index.ts index 5cace38a711b..0dcea5685e42 100644 --- a/extensions/baseten/index.ts +++ b/extensions/baseten/index.ts @@ -1,10 +1,15 @@ /** Baseten provider plugin entrypoint. */ +import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared"; import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; -import { BASETEN_DEFAULT_MODEL_REF, resolveBasetenDynamicModel } from "./models.js"; +import { + BASETEN_DEFAULT_MODEL_REF, + projectBasetenLiveModels, + resolveBasetenDynamicModel, +} from "./models.js"; import { applyBasetenConfig } from "./onboard.js"; -import { buildBasetenProvider, buildStaticBasetenProvider } from "./provider-catalog.js"; +import { buildStaticBasetenProvider } from "./provider-catalog.js"; import { createBasetenThinkingWrapper } from "./stream.js"; import { resolveBasetenThinkingProfile } from "./thinking.js"; @@ -46,11 +51,21 @@ export default defineSingleProviderPluginEntry({ if (!apiKey) { return null; } + if (!discoveryApiKey) { + return { provider: { ...buildStaticBasetenProvider(), apiKey } }; + } return { - provider: { - ...(await buildBasetenProvider(discoveryApiKey)), + provider: await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: PROVIDER_ID, + providerConfig: buildStaticBasetenProvider(), apiKey, - }, + discoveryApiKey, + modelDiscovery: { + timeoutMs: 10_000, + ttlMs: 5 * 60 * 1000, + projectRows: projectBasetenLiveModels, + }, + }), }; }, staticRun: async () => ({ provider: buildStaticBasetenProvider() }), diff --git a/extensions/baseten/models.test.ts b/extensions/baseten/models.test.ts index c02c67c9c5bd..49778f8da0fb 100644 --- a/extensions/baseten/models.test.ts +++ b/extensions/baseten/models.test.ts @@ -1,4 +1,5 @@ import { + buildOpenAICompatibleLiveModelProviderConfig, clearLiveCatalogCacheForTests, type LiveModelCatalogFetchGuard, } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; @@ -7,13 +8,34 @@ import { BASETEN_DEFAULT_MODEL_REF, BASETEN_MODEL_CATALOG, buildStaticBasetenModels, - discoverBasetenModels, projectBasetenLiveModels, resolveBasetenDynamicModel, } from "./models.js"; const TEST_VALUE = "fixture"; +async function buildLiveBasetenModels(params: { + discoveryApiKey: string; + fetchGuard: LiveModelCatalogFetchGuard; +}) { + const provider = await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: "baseten", + providerConfig: { + baseUrl: "https://inference.baseten.co/v1", + api: "openai-completions", + models: buildStaticBasetenModels(), + }, + discoveryApiKey: params.discoveryApiKey, + fetchGuard: params.fetchGuard, + modelDiscovery: { + timeoutMs: 10_000, + ttlMs: 5 * 60 * 1000, + projectRows: projectBasetenLiveModels, + }, + }); + return provider.models; +} + describe("Baseten model catalog", () => { beforeEach(() => { clearLiveCatalogCacheForTests(); @@ -133,10 +155,6 @@ describe("Baseten model catalog", () => { }); }); - it("keeps discovery offline without resolved auth", async () => { - await expect(discoverBasetenModels()).resolves.toHaveLength(9); - }); - it("authenticates live discovery and does not cache unusable rows", async () => { const release = vi.fn(async () => undefined); const fetchGuard: LiveModelCatalogFetchGuard = vi @@ -163,18 +181,10 @@ describe("Baseten model catalog", () => { })); await expect( - discoverBasetenModels({ - discoveryApiKey: TEST_VALUE, - forceLive: true, - fetchGuard, - }), + buildLiveBasetenModels({ discoveryApiKey: TEST_VALUE, fetchGuard }), ).resolves.toHaveLength(9); await expect( - discoverBasetenModels({ - discoveryApiKey: TEST_VALUE, - forceLive: true, - fetchGuard, - }), + buildLiveBasetenModels({ discoveryApiKey: TEST_VALUE, fetchGuard }), ).resolves.toEqual([ expect.objectContaining({ id: "thinkingmachines/inkling", diff --git a/extensions/baseten/models.ts b/extensions/baseten/models.ts index 1a7654c27e08..610a1857dbb1 100644 --- a/extensions/baseten/models.ts +++ b/extensions/baseten/models.ts @@ -1,10 +1,6 @@ /** - * Baseten model catalog, compat metadata, and authenticated live discovery. + * Baseten model catalog, compat metadata, and live row projection. */ -import { - getCachedLiveProviderModelRows, - type LiveModelCatalogFetchGuard, -} from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import { buildManifestModelDefinition, readManifestProviderDefaultModelRef, @@ -13,13 +9,9 @@ import type { ModelCompatConfig, ModelDefinitionConfig, } from "openclaw/plugin-sdk/provider-model-shared"; -import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; -import { ssrfPolicyFromHttpBaseUrlAllowedHostname } from "openclaw/plugin-sdk/ssrf-runtime"; import manifest from "./openclaw.plugin.json" with { type: "json" }; -const log = createSubsystemLogger("baseten-models"); const BASETEN_MANIFEST_CATALOG = manifest.modelCatalog.providers.baseten; -const CACHE_TTL_MS = 5 * 60 * 1000; const DEFAULT_CONTEXT_WINDOW = 128_000; const DEFAULT_MAX_TOKENS = 8_192; @@ -247,49 +239,6 @@ export function projectBasetenLiveModels(rows: readonly unknown[]): ModelDefinit return models; } -/** Discovers every model enabled for a Baseten account, with a static fallback. */ -export async function discoverBasetenModels( - params: { - discoveryApiKey?: string; - env?: Record; - forceLive?: boolean; - fetchGuard?: LiveModelCatalogFetchGuard; - signal?: AbortSignal; - } = {}, -): Promise { - const staticModels = buildStaticBasetenModels(); - const env = params.env ?? process.env; - if ( - !params.discoveryApiKey?.trim() || - (!params.forceLive && (env.NODE_ENV === "test" || env.VITEST === "true")) - ) { - return staticModels; - } - - try { - const rows = await getCachedLiveProviderModelRows({ - providerId: "baseten", - endpoint: `${BASETEN_BASE_URL}/models`, - discoveryApiKey: params.discoveryApiKey, - fetchGuard: params.fetchGuard, - signal: params.signal, - timeoutMs: 10_000, - ttlMs: CACHE_TTL_MS, - policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(BASETEN_BASE_URL), - auditContext: "baseten-model-discovery", - shouldCacheRows: (candidateRows) => projectBasetenLiveModels(candidateRows).length > 0, - }); - const models = projectBasetenLiveModels(rows); - if (models.length > 0) { - return models; - } - log.warn("Baseten returned no usable models; using bundled catalog"); - } catch (error) { - log.warn(`Baseten model discovery failed; using bundled catalog: ${String(error)}`); - } - return staticModels; -} - /** Resolves a forward-compatible Baseten model id not yet in the bundled catalog. */ export function resolveBasetenDynamicModel(modelId: string) { const id = modelId.trim(); diff --git a/extensions/baseten/provider-catalog.ts b/extensions/baseten/provider-catalog.ts index 07c591a1ed48..c990ac1b7520 100644 --- a/extensions/baseten/provider-catalog.ts +++ b/extensions/baseten/provider-catalog.ts @@ -1,6 +1,6 @@ /** Baseten static and authenticated provider catalog builders. */ import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; -import { BASETEN_BASE_URL, buildStaticBasetenModels, discoverBasetenModels } from "./models.js"; +import { BASETEN_BASE_URL, buildStaticBasetenModels } from "./models.js"; /** Builds Baseten's network-free fallback provider catalog. */ export function buildStaticBasetenProvider(): ModelProviderConfig { @@ -10,12 +10,3 @@ export function buildStaticBasetenProvider(): ModelProviderConfig { models: buildStaticBasetenModels(), }; } - -/** Builds Baseten's account-scoped live catalog. */ -export async function buildBasetenProvider(discoveryApiKey?: string): Promise { - return { - baseUrl: BASETEN_BASE_URL, - api: "openai-completions", - models: await discoverBasetenModels({ discoveryApiKey }), - }; -} diff --git a/extensions/chutes/models.ts b/extensions/chutes/models.ts index 7b8ebf66f233..5ce1cca09032 100644 --- a/extensions/chutes/models.ts +++ b/extensions/chutes/models.ts @@ -2,13 +2,9 @@ * Chutes model catalog, static model definitions, and dynamic model discovery. */ import { withTrustedEnvProxyGuardedFetchMode } from "openclaw/plugin-sdk/fetch-runtime"; -import { - getCachedLiveProviderModelRows, - LiveModelCatalogHttpError, -} from "openclaw/plugin-sdk/provider-catalog-live-runtime"; +import { buildLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import { buildManifestModelDefinition } from "openclaw/plugin-sdk/provider-catalog-shared"; import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; -import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, @@ -21,8 +17,6 @@ import { import { isChutesModelDiscoveryTestEnvironment } from "./model-discovery-env.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; -const log = createSubsystemLogger("chutes-models"); - const CHUTES_MANIFEST_CATALOG = manifest.modelCatalog.providers.chutes; /** Base URL for Chutes OpenAI-compatible inference. */ @@ -67,11 +61,57 @@ interface ChutesModelEntry { const CACHE_TTL = 5 * 60 * 1000; -async function fetchChutesModelRows(accessToken?: string): Promise { - return await getCachedLiveProviderModelRows({ +function projectChutesModels(rows: readonly unknown[]): ModelDefinitionConfig[] { + const seen = new Set(); + const models: ModelDefinitionConfig[] = []; + for (const row of rows) { + if (!row || typeof row !== "object" || Array.isArray(row)) { + continue; + } + const entry = row as ChutesModelEntry; + const id = normalizeOptionalString(entry.id) ?? ""; + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + const lowerId = normalizeLowercaseStringOrEmpty(id); + models.push({ + id, + name: id, + reasoning: + entry.supported_features?.includes("reasoning") || + lowerId.includes("r1") || + lowerId.includes("thinking") || + lowerId.includes("reason") || + lowerId.includes("tee"), + input: (entry.input_modalities || ["text"]).filter( + (item): item is "text" | "image" => item === "text" || item === "image", + ), + cost: { + input: entry.pricing?.prompt || 0, + output: entry.pricing?.completion || 0, + cacheRead: entry.pricing?.input_cache_read || 0, + cacheWrite: 0, + }, + contextWindow: asPositiveSafeInteger(entry.context_length) ?? CHUTES_DEFAULT_CONTEXT_WINDOW, + maxTokens: asPositiveSafeInteger(entry.max_output_length) ?? CHUTES_DEFAULT_MAX_TOKENS, + compat: { supportsUsageInStreaming: false }, + }); + } + return models; +} + +/** Discovers Chutes models dynamically, falling back to the bundled static catalog. */ +export async function discoverChutesModels(accessToken?: string): Promise { + if (isChutesModelDiscoveryTestEnvironment()) { + return structuredClone(CHUTES_MODEL_CATALOG); + } + const provider = await buildLiveModelProviderConfig({ providerId: "chutes", endpoint: `${CHUTES_BASE_URL}/models`, - discoveryApiKey: accessToken, + providerConfig: { baseUrl: CHUTES_BASE_URL, api: "openai-completions" }, + models: structuredClone(CHUTES_MODEL_CATALOG), + discoveryApiKey: normalizeOptionalString(accessToken), timeoutMs: 10_000, ttlMs: CACHE_TTL, buildRequestHeaders: ({ discoveryApiKey }) => ({ @@ -81,84 +121,8 @@ async function fetchChutesModelRows(accessToken?: string): Promise fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode(params)), + fallbackToAnonymousOnUnauthorized: true, + projectRows: projectChutesModels, }); -} - -/** Discovers Chutes models dynamically, falling back to the bundled static catalog. */ -export async function discoverChutesModels(accessToken?: string): Promise { - const trimmedKey = normalizeOptionalString(accessToken) ?? ""; - - if (isChutesModelDiscoveryTestEnvironment()) { - return structuredClone(CHUTES_MODEL_CATALOG); - } - - const staticCatalog = () => structuredClone(CHUTES_MODEL_CATALOG); - - try { - const data = await fetchChutesModelRows(trimmedKey || undefined); - if (data.length === 0) { - log.warn("No models in response, using static catalog"); - return staticCatalog(); - } - - const seen = new Set(); - const models: ModelDefinitionConfig[] = []; - - for (const entry of data as ChutesModelEntry[]) { - const id = normalizeOptionalString(entry?.id) ?? ""; - if (!id || seen.has(id)) { - continue; - } - seen.add(id); - - const lowerId = normalizeLowercaseStringOrEmpty(id); - const isReasoning = - entry.supported_features?.includes("reasoning") || - lowerId.includes("r1") || - lowerId.includes("thinking") || - lowerId.includes("reason") || - lowerId.includes("tee"); - - const input: Array<"text" | "image"> = (entry.input_modalities || ["text"]).filter( - (i): i is "text" | "image" => i === "text" || i === "image", - ); - - models.push({ - id, - name: id, - reasoning: isReasoning, - input, - cost: { - input: entry.pricing?.prompt || 0, - output: entry.pricing?.completion || 0, - cacheRead: entry.pricing?.input_cache_read || 0, - cacheWrite: 0, - }, - contextWindow: asPositiveSafeInteger(entry.context_length) ?? CHUTES_DEFAULT_CONTEXT_WINDOW, - maxTokens: asPositiveSafeInteger(entry.max_output_length) ?? CHUTES_DEFAULT_MAX_TOKENS, - compat: { - supportsUsageInStreaming: false, - }, - }); - } - - if (models.length === 0) { - return staticCatalog(); - } - return models; - } catch (error) { - if (error instanceof LiveModelCatalogHttpError && error.status === 401 && trimmedKey) { - return await discoverChutesModels(undefined); - } - if ( - error instanceof LiveModelCatalogHttpError && - error.status !== 401 && - error.status !== 503 - ) { - log.warn(`GET /v1/models failed: HTTP ${error.status}, using static catalog`); - return staticCatalog(); - } - log.warn(`Discovery failed: ${String(error)}, using static catalog`); - return staticCatalog(); - } + return provider.models; } diff --git a/extensions/opencode/provider-catalog.ts b/extensions/opencode/provider-catalog.ts index eda59364f801..7b81ca31cc65 100644 --- a/extensions/opencode/provider-catalog.ts +++ b/extensions/opencode/provider-catalog.ts @@ -2,7 +2,7 @@ import type { ModelCatalogEntry } from "openclaw/plugin-sdk/agent-runtime"; import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; import { - getCachedLiveProviderModelRows, + buildLiveModelProviderConfig, type LiveModelCatalogFetchGuard, } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared"; @@ -463,22 +463,15 @@ const OPENCODE_ZEN_MODELS = [ "north-mini-code-free", ].map(buildOpencodeZenModel); -function buildOpencodeZenProviderConfig( - models: OpencodeZenModelDefinition[], - apiKey?: string, -): ModelProviderConfig { +export function buildStaticOpencodeZenProviderConfig(apiKey?: string): ModelProviderConfig { return { api: "openai-completions", baseUrl: OPENCODE_ZEN_OPENAI_BASE_URL, ...(apiKey ? { apiKey } : {}), - models, + models: OPENCODE_ZEN_MODELS, }; } -export function buildStaticOpencodeZenProviderConfig(apiKey?: string): ModelProviderConfig { - return buildOpencodeZenProviderConfig(OPENCODE_ZEN_MODELS, apiKey); -} - function readLiveModelId(row: unknown): string | undefined { if (!row || typeof row !== "object" || Array.isArray(row)) { return undefined; @@ -494,12 +487,35 @@ function readLiveModelId(row: unknown): string | undefined { return modelId || undefined; } -async function fetchOpencodeZenLiveModelIds( +function projectOpencodeZenLiveModels(rows: readonly unknown[]): OpencodeZenModelDefinition[] { + const staticModels = new Map(OPENCODE_ZEN_MODELS.map((model) => [model.id, model])); + const seen = new Set(); + const models: OpencodeZenModelDefinition[] = []; + for (const row of rows) { + const modelId = readLiveModelId(row); + if (!modelId || seen.has(modelId)) { + continue; + } + seen.add(modelId); + const model = staticModels.get(modelId); + if (model) { + models.push(model); + } + } + return models; +} + +export async function buildOpencodeZenLiveProviderConfig( params: FetchOpencodeZenLiveModelIdsParams = {}, -): Promise { - const rows = await getCachedLiveProviderModelRows({ +): Promise { + return await buildLiveModelProviderConfig({ providerId: PROVIDER_ID, endpoint: OPENCODE_ZEN_MODELS_ENDPOINT, + providerConfig: { + api: "openai-completions", + baseUrl: OPENCODE_ZEN_OPENAI_BASE_URL, + }, + models: OPENCODE_ZEN_MODELS, apiKey: params.apiKey, discoveryApiKey: params.discoveryApiKey, fetchGuard: params.fetchGuard, @@ -507,43 +523,8 @@ async function fetchOpencodeZenLiveModelIds( timeoutMs: OPENCODE_ZEN_MODELS_TIMEOUT_MS, ttlMs: OPENCODE_ZEN_MODELS_CACHE_TTL_MS, auditContext: "opencode-zen-model-discovery", + projectRows: projectOpencodeZenLiveModels, }); - const seen = new Set(); - const modelIds: string[] = []; - for (const row of rows) { - const modelId = readLiveModelId(row); - if (!modelId || seen.has(modelId)) { - continue; - } - seen.add(modelId); - modelIds.push(modelId); - } - return modelIds; -} - -function buildDiscoveredOpencodeZenModels(modelIds: string[]): OpencodeZenModelDefinition[] { - const staticModels = new Map(OPENCODE_ZEN_MODELS.map((model) => [model.id, model])); - return modelIds.flatMap((modelId) => { - const model = staticModels.get(modelId); - return model ? [model] : []; - }); -} - -export async function buildOpencodeZenLiveProviderConfig( - params: FetchOpencodeZenLiveModelIdsParams = {}, -): Promise { - try { - const liveModelIds = await fetchOpencodeZenLiveModelIds(params); - if (liveModelIds.length > 0) { - const liveModels = buildDiscoveredOpencodeZenModels(liveModelIds); - if (liveModels.length > 0) { - return buildOpencodeZenProviderConfig(liveModels, params.apiKey); - } - } - } catch { - // Live discovery is advisory; keep the provider-owned static seed visible. - } - return buildStaticOpencodeZenProviderConfig(params.apiKey); } export function listOpencodeZenModelCatalogEntries(): ModelCatalogEntry[] { diff --git a/extensions/openrouter/provider-catalog.ts b/extensions/openrouter/provider-catalog.ts index 76d7f743eea0..22752b5e296d 100644 --- a/extensions/openrouter/provider-catalog.ts +++ b/extensions/openrouter/provider-catalog.ts @@ -1,6 +1,6 @@ // Openrouter provider module implements model/runtime integration. import { - getCachedLiveProviderModelRows, + buildLiveModelProviderConfig, type LiveModelCatalogFetchGuard, } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import type { @@ -196,36 +196,31 @@ export async function buildOpenrouterLiveProvider(params: { fetchGuard?: LiveModelCatalogFetchGuard; signal?: AbortSignal; }): Promise { - const fallback = { - ...buildOpenrouterProvider(), - ...(params.apiKey ? { apiKey: params.apiKey } : {}), - }; - try { - const rows = await getCachedLiveProviderModelRows({ - providerId: "openrouter", - endpoint: OPENROUTER_MODELS_ENDPOINT, - apiKey: params.apiKey, - discoveryApiKey: params.discoveryApiKey, - fetchGuard: params.fetchGuard, - signal: params.signal, - ttlMs: OPENROUTER_MODELS_CACHE_TTL_MS, - auditContext: "openrouter-model-discovery", - shouldCacheRows: (modelRows) => parseOpenRouterLiveModels(modelRows).length > 0, - }); - const liveModels = parseOpenRouterLiveModels(rows); - if (liveModels.length === 0) { - return fallback; - } - const models = new Map(fallback.models.map((model) => [model.id, model])); - for (const model of liveModels) { - models.set(model.id, model); - } - return { - ...fallback, - models: [...models.values()].toSorted((a, b) => a.id.localeCompare(b.id)), - }; - } catch { - // Discovery is advisory; retain the bundled seed when OpenRouter is unavailable. - return fallback; - } + const fallback = buildOpenrouterProvider(); + return await buildLiveModelProviderConfig({ + providerId: "openrouter", + endpoint: OPENROUTER_MODELS_ENDPOINT, + providerConfig: { + baseUrl: fallback.baseUrl, + api: fallback.api, + }, + models: fallback.models, + apiKey: params.apiKey, + discoveryApiKey: params.discoveryApiKey, + fetchGuard: params.fetchGuard, + signal: params.signal, + ttlMs: OPENROUTER_MODELS_CACHE_TTL_MS, + auditContext: "openrouter-model-discovery", + projectRows: (rows, fallbackProvider) => { + const liveModels = parseOpenRouterLiveModels(rows); + if (liveModels.length === 0) { + return []; + } + const models = new Map(fallbackProvider.models.map((model) => [model.id, model])); + for (const model of liveModels) { + models.set(model.id, model); + } + return [...models.values()].toSorted((a, b) => a.id.localeCompare(b.id)); + }, + }); } diff --git a/extensions/venice/api.ts b/extensions/venice/api.ts index 06e3ff028697..d45dfcd4dd96 100644 --- a/extensions/venice/api.ts +++ b/extensions/venice/api.ts @@ -1,8 +1,2 @@ // Venice API module exposes the plugin public contract. -export { - discoverVeniceModels, - VENICE_BASE_URL, - VENICE_DEFAULT_MODEL_REF, - VENICE_MODEL_CATALOG, -} from "./models.js"; -export { buildVeniceProvider } from "./provider-catalog.js"; +export { VENICE_BASE_URL, VENICE_DEFAULT_MODEL_REF, VENICE_MODEL_CATALOG } from "./models.js"; diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index 6577ada4e216..4c960c69681a 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -5,9 +5,9 @@ import { type ModelCompatConfig, } from "openclaw/plugin-sdk/provider-model-shared"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { VENICE_DEFAULT_MODEL_REF } from "./models.js"; +import { VENICE_DEFAULT_MODEL_REF, VENICE_MODEL_DISCOVERY_OPTIONS } from "./models.js"; import { applyVeniceConfig } from "./onboard.js"; -import { buildVeniceProvider } from "./provider-catalog.js"; +import { buildStaticVeniceProvider } from "./provider-catalog.js"; import { createVeniceDeepSeekV4Wrapper } from "./stream.js"; import { fetchVeniceUsage } from "./usage.js"; @@ -63,7 +63,8 @@ export default defineSingleProviderPluginEntry({ }, ], catalog: { - buildProvider: buildVeniceProvider, + buildProvider: buildStaticVeniceProvider, + liveModelDiscovery: VENICE_MODEL_DISCOVERY_OPTIONS, }, normalizeResolvedModel: ({ modelId, model }) => isXaiBackedVeniceModel(modelId) ? applyXaiModelCompat(model) : undefined, diff --git a/extensions/venice/models.test.ts b/extensions/venice/models.test.ts index e48fbfb098e8..97e4255a4637 100644 --- a/extensions/venice/models.test.ts +++ b/extensions/venice/models.test.ts @@ -1,8 +1,11 @@ // Venice tests cover models plugin behavior. import { expectDefined } from "@openclaw/normalization-core"; -import { clearLiveCatalogCacheForTests } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; +import { + buildOpenAICompatibleLiveModelProviderConfig, + clearLiveCatalogCacheForTests, +} from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { discoverVeniceModels, VENICE_MODEL_CATALOG } from "./models.js"; +import { VENICE_BASE_URL, VENICE_MODEL_CATALOG, VENICE_MODEL_DISCOVERY_OPTIONS } from "./models.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; const ORIGINAL_NODE_ENV = process.env.NODE_ENV; @@ -93,7 +96,7 @@ function makeModelRow(params: ModelSpecOverride) { function stubVeniceModelsFetch(rows: ModelSpecOverride[]) { const fetchMock = vi.fn( - async () => + async (_input: string | URL | Request, _init?: RequestInit) => new Response( JSON.stringify({ data: rows.map((row) => makeModelRow(row)), @@ -108,6 +111,19 @@ function stubVeniceModelsFetch(rows: ModelSpecOverride[]) { return fetchMock; } +async function discoverVeniceModels() { + const provider = await buildOpenAICompatibleLiveModelProviderConfig({ + providerId: "venice", + providerConfig: { + baseUrl: VENICE_BASE_URL, + api: "openai-completions", + models: structuredClone(VENICE_MODEL_CATALOG), + }, + modelDiscovery: VENICE_MODEL_DISCOVERY_OPTIONS, + }); + return provider.models; +} + describe("venice-models", () => { afterEach(() => { clearLiveCatalogCacheForTests(); @@ -181,11 +197,11 @@ describe("venice-models", () => { } }); - it("retries transient fetch failures before succeeding", async () => { + it("uses the shared fallback after a transient fetch failure", async () => { let attempts = 0; const fetchMock = vi.fn(async () => { attempts += 1; - if (attempts < 3) { + if (attempts === 1) { throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET", message: "socket hang up" }, }); @@ -194,13 +210,13 @@ describe("venice-models", () => { }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - const models = await runWithDiscoveryEnabled(() => discoverVeniceModels({ retryDelayMs: 0 })); - expect(attempts).toBe(3); - expect(models.map((m) => m.id)).toContain("zai-org-glm-4.7"); + const models = await runWithDiscoveryEnabled(() => discoverVeniceModels()); + expect(attempts).toBe(1); + expect(models.map((m) => m.id)).toEqual(VENICE_MODEL_CATALOG.map((m) => m.id)); }); it("uses API maxCompletionTokens for catalog models when present", async () => { - stubVeniceModelsFetch([ + const fetchMock = stubVeniceModelsFetch([ { id: "zai-org-glm-4.7", availableContextTokens: 131072, @@ -213,9 +229,13 @@ describe("venice-models", () => { }, ]); - const models = await runWithDiscoveryEnabled(() => discoverVeniceModels({ retryDelayMs: 0 })); + const models = await runWithDiscoveryEnabled(() => discoverVeniceModels()); const glm = models.find((m) => m.id === "zai-org-glm-4.7"); expect(glm?.maxTokens).toBe(2048); + const [input, init] = fetchMock.mock.calls[0] ?? []; + const headers = input instanceof Request ? input.headers : new Headers(init?.headers); + expect(headers.get("accept")).toBe("application/json"); + expect(headers.get("authorization")).toBeNull(); }); it("retains catalog maxTokens when the API omits maxCompletionTokens", async () => { @@ -231,7 +251,7 @@ describe("venice-models", () => { }, ]); - const models = await runWithDiscoveryEnabled(() => discoverVeniceModels({ retryDelayMs: 0 })); + const models = await runWithDiscoveryEnabled(() => discoverVeniceModels()); const qwen = models.find((m) => m.id === "qwen3-235b-a22b-thinking-2507"); expect(qwen?.maxTokens).toBe(16384); }); @@ -255,7 +275,7 @@ describe("venice-models", () => { }, ]); - const models = await runWithDiscoveryEnabled(() => discoverVeniceModels({ retryDelayMs: 0 })); + const models = await runWithDiscoveryEnabled(() => discoverVeniceModels()); const newModel = models.find((m) => m.id === "new-model-2026"); expect(newModel?.maxTokens).toBe(50000); expect(newModel?.maxTokens).toBeLessThanOrEqual(newModel?.contextWindow ?? Infinity); @@ -328,7 +348,7 @@ describe("venice-models", () => { expect(newModel?.maxTokens).toBe(2048); }); - it("falls back to static catalog after retry budget is exhausted", async () => { + it("falls back to static catalog after a discovery failure", async () => { const fetchMock = vi.fn(async () => { throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ENOTFOUND", message: "getaddrinfo ENOTFOUND api.venice.ai" }, @@ -336,8 +356,8 @@ describe("venice-models", () => { }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - const models = await runWithDiscoveryEnabled(() => discoverVeniceModels({ retryDelayMs: 0 })); - expect(fetchMock).toHaveBeenCalledTimes(3); + const models = await runWithDiscoveryEnabled(() => discoverVeniceModels()); + expect(fetchMock).toHaveBeenCalledOnce(); expect(models).toHaveLength(VENICE_MODEL_CATALOG.length); expect(models.map((m) => m.id)).toEqual(VENICE_MODEL_CATALOG.map((m) => m.id)); }); diff --git a/extensions/venice/models.ts b/extensions/venice/models.ts index b5fad01a719b..ac687f98d9d4 100644 --- a/extensions/venice/models.ts +++ b/extensions/venice/models.ts @@ -1,22 +1,18 @@ -import { - getCachedLiveProviderModelRows, - LiveModelCatalogHttpError, -} from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import { buildManifestModelDefinition, readManifestProviderDefaultModelRef, } from "openclaw/plugin-sdk/provider-catalog-shared"; -import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; -import { createSubsystemLogger, retryAsync } from "openclaw/plugin-sdk/runtime-env"; +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-model-shared"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import manifest from "./openclaw.plugin.json" with { type: "json" }; -const log = createSubsystemLogger("venice-models"); const VENICE_MANIFEST_CATALOG = manifest.modelCatalog.providers.venice; export const VENICE_BASE_URL = VENICE_MANIFEST_CATALOG.baseUrl; export const VENICE_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef(manifest, "venice")!; -const VENICE_ALLOWED_HOSTNAMES = ["api.venice.ai"]; const VENICE_DEFAULT_COST = { input: 0, @@ -30,22 +26,6 @@ const VENICE_DEFAULT_MAX_TOKENS = 4096; const VENICE_DISCOVERY_HARD_MAX_TOKENS = 131_072; const VENICE_DISCOVERY_TIMEOUT_MS = 10_000; const VENICE_DISCOVERY_CACHE_TTL_MS = 60_000; -const VENICE_DISCOVERY_RETRYABLE_HTTP_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); -const VENICE_DISCOVERY_RETRYABLE_NETWORK_CODES = new Set([ - "ECONNABORTED", - "ECONNREFUSED", - "ECONNRESET", - "EAI_AGAIN", - "ENETDOWN", - "ENETUNREACH", - "ENOTFOUND", - "ETIMEDOUT", - "UND_ERR_BODY_TIMEOUT", - "UND_ERR_CONNECT_TIMEOUT", - "UND_ERR_CONNECT_ERROR", - "UND_ERR_HEADERS_TIMEOUT", - "UND_ERR_SOCKET", -]); function decorateVeniceModelDefinition(entry: ModelDefinitionConfig): ModelDefinitionConfig { return { @@ -89,53 +69,6 @@ interface VeniceModel { model_spec?: VeniceModelSpec; } -function hasRetryableNetworkCode(err: unknown): boolean { - const queue: unknown[] = [err]; - const seen = new Set(); - while (queue.length > 0) { - const current = queue.shift(); - if (!current || typeof current !== "object" || seen.has(current)) { - continue; - } - seen.add(current); - const candidate = current as { - cause?: unknown; - errors?: unknown; - code?: unknown; - errno?: unknown; - }; - const code = - typeof candidate.code === "string" - ? candidate.code - : typeof candidate.errno === "string" - ? candidate.errno - : undefined; - if (code && VENICE_DISCOVERY_RETRYABLE_NETWORK_CODES.has(code)) { - return true; - } - if (candidate.cause) { - queue.push(candidate.cause); - } - if (Array.isArray(candidate.errors)) { - queue.push(...candidate.errors); - } - } - return false; -} - -function isRetryableVeniceDiscoveryError(err: unknown): boolean { - if (err instanceof LiveModelCatalogHttpError) { - return VENICE_DISCOVERY_RETRYABLE_HTTP_STATUS.has(err.status); - } - if (err instanceof Error && err.name === "AbortError") { - return true; - } - if (err instanceof TypeError && normalizeLowercaseStringOrEmpty(err.message) === "fetch failed") { - return true; - } - return hasRetryableNetworkCode(err); -} - function normalizePositiveInt(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { return undefined; @@ -166,107 +99,74 @@ function resolveApiSupportsTools(apiModel: VeniceModel): boolean | undefined { return typeof supportsFunctionCalling === "boolean" ? supportsFunctionCalling : undefined; } -type VeniceModelDiscoveryOptions = { - retryDelayMs?: number; -}; - -export async function discoverVeniceModels( - options: VeniceModelDiscoveryOptions = {}, -): Promise { - if (process.env.NODE_ENV === "test" || process.env.VITEST) { - return structuredClone(VENICE_MODEL_CATALOG); - } - - try { - const data = await retryAsync( - async () => - await getCachedLiveProviderModelRows({ - providerId: "venice", - endpoint: `${VENICE_BASE_URL}/models`, - timeoutMs: VENICE_DISCOVERY_TIMEOUT_MS, - ttlMs: VENICE_DISCOVERY_CACHE_TTL_MS, - policy: { allowedHostnames: VENICE_ALLOWED_HOSTNAMES }, - auditContext: "venice-model-discovery", - }), - { - attempts: 3, - minDelayMs: options.retryDelayMs ?? 300, - maxDelayMs: options.retryDelayMs ?? 2000, - jitter: options.retryDelayMs === undefined ? 0.2 : 0, - label: "venice-model-discovery", - shouldRetry: isRetryableVeniceDiscoveryError, - }, - ); - - if (data.length === 0) { - log.warn("No models found from API, using static catalog"); - return structuredClone(VENICE_MODEL_CATALOG); +function projectVeniceModels( + rows: readonly unknown[], + fallback: ModelProviderConfig, +): ModelDefinitionConfig[] { + const catalogById = new Map(fallback.models.map((model) => [model.id, model])); + const models: ModelDefinitionConfig[] = []; + for (const row of rows) { + if (!row || typeof row !== "object" || Array.isArray(row)) { + continue; } - - const catalogById = new Map( - structuredClone(VENICE_MODEL_CATALOG).map((model) => [model.id, model]), - ); - const models: ModelDefinitionConfig[] = []; - - for (const apiModel of data as VeniceModel[]) { - const catalogEntry = catalogById.get(apiModel.id); - const apiMaxTokens = resolveApiMaxCompletionTokens({ - apiModel, - knownMaxTokens: catalogEntry?.maxTokens, - }); - const apiSupportsTools = resolveApiSupportsTools(apiModel); - if (catalogEntry) { - const definition: ModelDefinitionConfig = { - ...catalogEntry, - input: [...catalogEntry.input], - cost: { ...catalogEntry.cost }, - ...(catalogEntry.compat ? { compat: { ...catalogEntry.compat } } : {}), - }; - if (apiMaxTokens !== undefined) { - definition.maxTokens = apiMaxTokens; - } - if (apiSupportsTools === false) { - definition.compat = { - ...definition.compat, - supportsTools: false, - }; - } - models.push(definition); - } else { - const apiSpec = apiModel.model_spec; - const lowerModelId = normalizeLowercaseStringOrEmpty(apiModel.id); - const isReasoning = - apiSpec?.capabilities?.supportsReasoning || - lowerModelId.includes("thinking") || - lowerModelId.includes("reason") || - lowerModelId.includes("r1"); - - const hasVision = apiSpec?.capabilities?.supportsVision === true; - - models.push({ - id: apiModel.id, - name: apiSpec?.name || apiModel.id, - reasoning: isReasoning, - input: hasVision ? ["text", "image"] : ["text"], - cost: VENICE_DEFAULT_COST, - contextWindow: - normalizePositiveInt(apiSpec?.availableContextTokens) ?? VENICE_DEFAULT_CONTEXT_WINDOW, - maxTokens: apiMaxTokens ?? VENICE_DEFAULT_MAX_TOKENS, - compat: { - supportsUsageInStreaming: false, - ...(apiSupportsTools === false ? { supportsTools: false } : {}), - }, - }); + const apiModel = row as VeniceModel; + if (typeof apiModel.id !== "string" || !apiModel.id.trim()) { + continue; + } + const catalogEntry = catalogById.get(apiModel.id); + const apiMaxTokens = resolveApiMaxCompletionTokens({ + apiModel, + knownMaxTokens: catalogEntry?.maxTokens, + }); + const apiSupportsTools = resolveApiSupportsTools(apiModel); + if (catalogEntry) { + const definition: ModelDefinitionConfig = { + ...catalogEntry, + input: [...catalogEntry.input], + cost: { ...catalogEntry.cost }, + ...(catalogEntry.compat ? { compat: { ...catalogEntry.compat } } : {}), + }; + if (apiMaxTokens !== undefined) { + definition.maxTokens = apiMaxTokens; } + if (apiSupportsTools === false) { + definition.compat = { + ...definition.compat, + supportsTools: false, + }; + } + models.push(definition); + } else { + const apiSpec = apiModel.model_spec; + const lowerModelId = normalizeLowercaseStringOrEmpty(apiModel.id); + const isReasoning = + apiSpec?.capabilities?.supportsReasoning || + lowerModelId.includes("thinking") || + lowerModelId.includes("reason") || + lowerModelId.includes("r1"); + const hasVision = apiSpec?.capabilities?.supportsVision === true; + models.push({ + id: apiModel.id, + name: apiSpec?.name || apiModel.id, + reasoning: isReasoning, + input: hasVision ? ["text", "image"] : ["text"], + cost: VENICE_DEFAULT_COST, + contextWindow: + normalizePositiveInt(apiSpec?.availableContextTokens) ?? VENICE_DEFAULT_CONTEXT_WINDOW, + maxTokens: apiMaxTokens ?? VENICE_DEFAULT_MAX_TOKENS, + compat: { + supportsUsageInStreaming: false, + ...(apiSupportsTools === false ? { supportsTools: false } : {}), + }, + }); } - - return models.length > 0 ? models : structuredClone(VENICE_MODEL_CATALOG); - } catch (error) { - if (error instanceof LiveModelCatalogHttpError) { - log.warn(`Failed to discover models: HTTP ${error.status}, using static catalog`); - return structuredClone(VENICE_MODEL_CATALOG); - } - log.warn(`Discovery failed: ${String(error)}, using static catalog`); - return structuredClone(VENICE_MODEL_CATALOG); } + return models; } + +export const VENICE_MODEL_DISCOVERY_OPTIONS = { + timeoutMs: VENICE_DISCOVERY_TIMEOUT_MS, + ttlMs: VENICE_DISCOVERY_CACHE_TTL_MS, + buildRequestHeaders: () => ({ Accept: "application/json" }), + projectRows: projectVeniceModels, +} as const; diff --git a/extensions/venice/provider-catalog.ts b/extensions/venice/provider-catalog.ts index 82610c92084d..a6ce0e1fcaee 100644 --- a/extensions/venice/provider-catalog.ts +++ b/extensions/venice/provider-catalog.ts @@ -1,12 +1,11 @@ // Venice provider module implements model/runtime integration. import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; -import { discoverVeniceModels, VENICE_BASE_URL } from "./models.js"; +import { VENICE_BASE_URL, VENICE_MODEL_CATALOG } from "./models.js"; -export async function buildVeniceProvider(): Promise { - const models = await discoverVeniceModels(); +export function buildStaticVeniceProvider(): ModelProviderConfig { return { baseUrl: VENICE_BASE_URL, api: "openai-completions", - models, + models: structuredClone(VENICE_MODEL_CATALOG), }; } diff --git a/extensions/vercel-ai-gateway/models.ts b/extensions/vercel-ai-gateway/models.ts index c35a9ef00111..10d490195e3c 100644 --- a/extensions/vercel-ai-gateway/models.ts +++ b/extensions/vercel-ai-gateway/models.ts @@ -1,12 +1,8 @@ // Vercel Ai Gateway plugin module implements models behavior. import { withTrustedEnvProxyGuardedFetchMode } from "openclaw/plugin-sdk/fetch-runtime"; import { parseStrictFiniteNumber } from "openclaw/plugin-sdk/number-runtime"; -import { - getCachedLiveProviderModelRows, - LiveModelCatalogHttpError, -} from "openclaw/plugin-sdk/provider-catalog-live-runtime"; +import { buildLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; -import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { asPositiveSafeInteger } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -22,7 +18,6 @@ export const VERCEL_AI_GATEWAY_DEFAULT_COST = { cacheWrite: 0, } as const; -const log = createSubsystemLogger("agents/vercel-ai-gateway"); const VERCEL_AI_GATEWAY_DISCOVERY_CACHE_TTL_MS = 60_000; const VERCEL_AI_GATEWAY_DISCOVERY_TIMEOUT_MS = 5000; @@ -218,26 +213,23 @@ export async function discoverVercelAiGatewayModels(): Promise fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode(params)), - }); - const discovered = data - .map(asVercelGatewayModelShape) - .map(buildDiscoveredModelDefinition) - .filter((entry): entry is ModelDefinitionConfig => entry !== null); - return discovered.length > 0 ? discovered : getStaticVercelAiGatewayModelCatalog(); - } catch (error) { - if (error instanceof LiveModelCatalogHttpError) { - log.warn(`Failed to discover Vercel AI Gateway models: HTTP ${error.status}`); - return getStaticVercelAiGatewayModelCatalog(); - } - log.warn(`Failed to discover Vercel AI Gateway models: ${String(error)}`); - return getStaticVercelAiGatewayModelCatalog(); - } + const provider = await buildLiveModelProviderConfig({ + providerId: VERCEL_AI_GATEWAY_PROVIDER_ID, + endpoint: `${VERCEL_AI_GATEWAY_BASE_URL}/v1/models`, + providerConfig: { + baseUrl: VERCEL_AI_GATEWAY_BASE_URL, + api: "anthropic-messages", + }, + models: getStaticVercelAiGatewayModelCatalog(), + timeoutMs: VERCEL_AI_GATEWAY_DISCOVERY_TIMEOUT_MS, + ttlMs: VERCEL_AI_GATEWAY_DISCOVERY_CACHE_TTL_MS, + auditContext: "vercel-ai-gateway.models", + fetchGuard: (params) => fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode(params)), + projectRows: (rows) => + rows + .map(asVercelGatewayModelShape) + .map(buildDiscoveredModelDefinition) + .filter((entry): entry is ModelDefinitionConfig => entry !== null), + }); + return provider.models; } diff --git a/extensions/xai/provider-catalog.ts b/extensions/xai/provider-catalog.ts index 0a0c4e23f2f4..fd535cae8a42 100644 --- a/extensions/xai/provider-catalog.ts +++ b/extensions/xai/provider-catalog.ts @@ -1,7 +1,6 @@ // Xai provider module implements model/runtime integration. import { buildLiveModelProviderConfig, - getCachedLiveProviderModelRows, type LiveModelCatalogFetchGuard, } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import type { @@ -177,36 +176,30 @@ export async function buildLiveXaiOAuthProvider(params: { fetchGuard?: LiveModelCatalogFetchGuard; signal?: AbortSignal; }): Promise { - try { - const rows = await getCachedLiveProviderModelRows({ - providerId: PROVIDER_ID, - endpoint: XAI_GROK_OAUTH_MODELS_ENDPOINT, - discoveryApiKey: params.discoveryApiKey, - fetchGuard: params.fetchGuard, - signal: params.signal, - ttlMs: XAI_GROK_OAUTH_MODELS_CACHE_TTL_MS, - auditContext: "xai-grok-oauth-model-discovery", - cacheKeyParts: [ - PROVIDER_ID, - "grok-oauth-model-rows", - XAI_GROK_OAUTH_MODELS_ENDPOINT, - params.discoveryApiKey, - ], - }); - const models = rows - .map(buildXaiOauthModelFromLiveRow) - .filter((model): model is ModelDefinitionConfig => Boolean(model)); - if (models.length > 0) { - return { - baseUrl: XAI_GROK_OAUTH_BASE_URL, - api: "openai-responses", - auth: "oauth", - models, - }; - } - } catch { - // Grok subscription discovery is advisory. If the proxy is unavailable, - // preserve the OAuth proxy transport instead of publishing API-key rows. - } - return buildXaiOAuthFallbackProvider(); + const fallback = buildXaiOAuthFallbackProvider(); + return await buildLiveModelProviderConfig({ + providerId: PROVIDER_ID, + endpoint: XAI_GROK_OAUTH_MODELS_ENDPOINT, + providerConfig: { + baseUrl: fallback.baseUrl, + api: fallback.api, + auth: fallback.auth, + }, + models: fallback.models, + discoveryApiKey: params.discoveryApiKey, + fetchGuard: params.fetchGuard, + signal: params.signal, + ttlMs: XAI_GROK_OAUTH_MODELS_CACHE_TTL_MS, + auditContext: "xai-grok-oauth-model-discovery", + cacheKeyParts: [ + PROVIDER_ID, + "grok-oauth-model-rows", + XAI_GROK_OAUTH_MODELS_ENDPOINT, + params.discoveryApiKey, + ], + projectRows: (rows) => + rows + .map(buildXaiOauthModelFromLiveRow) + .filter((model): model is ModelDefinitionConfig => Boolean(model)), + }); } diff --git a/src/plugin-sdk/api-baseline.test.ts b/src/plugin-sdk/api-baseline.test.ts index 95e68d5bea32..8e3b589868f6 100644 --- a/src/plugin-sdk/api-baseline.test.ts +++ b/src/plugin-sdk/api-baseline.test.ts @@ -102,6 +102,9 @@ describe("Plugin SDK API baseline", () => { "constructor(providerId: string, status: number);", ); expect(findDeclaration("LiveModelCatalogHttpError")).not.toContain("super("); + expect(findDeclaration("LiveModelRowProjection")).toContain( + "export type LiveModelRowProjection", + ); expect(findDeclaration("ApprovalResolveResult")).not.toContain("see source"); expect(findDeclaration("RealtimeVoiceAgentConsultRuntime")).not.toContain("see source"); expect(findDeclaration("createWebSearchProviderContractFields")).toContain( diff --git a/src/plugin-sdk/provider-catalog-live-projection.test.ts b/src/plugin-sdk/provider-catalog-live-projection.test.ts new file mode 100644 index 000000000000..fbf69ff0abea --- /dev/null +++ b/src/plugin-sdk/provider-catalog-live-projection.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest"; +import { + buildLiveModelProviderConfig, + clearLiveCatalogCacheForTests, + type LiveModelCatalogFetchGuard, +} from "./provider-catalog-live-runtime.js"; +import type { ModelDefinitionConfig } from "./provider-model-shared.js"; + +function buildModel(id: string): ModelDefinitionConfig { + return { + id, + name: id, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8192, + }; +} + +describe("live provider catalog projection", () => { + beforeEach(() => clearLiveCatalogCacheForTests()); + + it("keeps cache admission and fallback shared", async () => { + const release = vi.fn(async () => undefined); + const fetchGuard: MockedFunction = vi + .fn() + .mockResolvedValueOnce({ + response: Response.json({ data: [{ slug: "" }] }), + finalUrl: "https://provider.example.test/v1/models", + release, + }) + .mockResolvedValueOnce({ + response: Response.json({ data: [{ slug: "projected-model" }] }), + finalUrl: "https://provider.example.test/v1/models", + release, + }); + const buildProvider = async () => + await buildLiveModelProviderConfig({ + providerId: "provider", + endpoint: "https://provider.example.test/v1/models", + providerConfig: { + api: "openai-completions", + baseUrl: "https://provider.example.test/v1", + }, + models: [buildModel("fallback-model")], + fetchGuard, + ttlMs: 60_000, + projectRows: (rows) => + rows.flatMap((row) => { + const slug = + row && typeof row === "object" && "slug" in row && typeof row.slug === "string" + ? row.slug.trim() + : ""; + return slug ? [buildModel(slug)] : []; + }), + }); + + expect((await buildProvider()).models.map((model) => model.id)).toEqual(["fallback-model"]); + expect((await buildProvider()).models.map((model) => model.id)).toEqual(["projected-model"]); + expect(fetchGuard).toHaveBeenCalledTimes(2); + expect(release).toHaveBeenCalledTimes(2); + }); + + it("caches the anonymous fallback independently after an authenticated 401", async () => { + const release = vi.fn(async () => undefined); + const fetchGuard: MockedFunction = vi.fn(async ({ init }) => ({ + response: new Headers(init?.headers).has("authorization") + ? new Response("", { status: 401 }) + : Response.json({ data: [{ id: "public-model", object: "model" }] }), + finalUrl: "https://provider.example.test/v1/models", + release, + })); + const buildProvider = async () => + await buildLiveModelProviderConfig({ + providerId: "provider", + endpoint: "https://provider.example.test/v1/models", + providerConfig: { + api: "openai-completions", + baseUrl: "https://provider.example.test/v1", + }, + models: [buildModel("fallback-model")], + apiKey: "runtime-key", + discoveryApiKey: "rejected-key", + fetchGuard, + ttlMs: 60_000, + fallbackToAnonymousOnUnauthorized: true, + projectRows: (rows) => + rows.flatMap((row) => + row && typeof row === "object" && "id" in row && typeof row.id === "string" + ? [buildModel(row.id)] + : [], + ), + }); + + const first = await buildProvider(); + const second = await buildProvider(); + expect(first.apiKey).toBe("runtime-key"); + expect(first.models.map((model) => model.id)).toEqual(["public-model"]); + expect(second.models.map((model) => model.id)).toEqual(["public-model"]); + expect(fetchGuard).toHaveBeenCalledTimes(3); + expect(release).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/plugin-sdk/provider-catalog-live-runtime.ts b/src/plugin-sdk/provider-catalog-live-runtime.ts index f4f339c35457..354d1ac1c686 100644 --- a/src/plugin-sdk/provider-catalog-live-runtime.ts +++ b/src/plugin-sdk/provider-catalog-live-runtime.ts @@ -53,6 +53,11 @@ export type CachedLiveProviderModelRowsParams = FetchLiveProviderModelRowsParams shouldCacheRows?: (rows: readonly unknown[]) => boolean; }; +export type LiveModelRowProjection = ( + rows: readonly unknown[], + fallback: ModelProviderConfig, +) => readonly T[]; + // Live model catalogs are fetched at runtime from provider-controlled endpoints, // so the success body is untrusted just like the error body. A faulty or hostile // provider can stream an unbounded JSON document; reading it without a ceiling @@ -79,6 +84,10 @@ export type BuildLiveModelProviderConfigParams models: readonly T[]; ttlMs?: number; cacheKeyParts?: readonly unknown[]; + /** Provider-owned projection for catalogs that publish richer metadata than model ids. */ + projectRows?: LiveModelRowProjection; + /** Retry a rejected authenticated catalog request against the provider's public catalog. */ + fallbackToAnonymousOnUnauthorized?: boolean; }; export type OpenAICompatibleModelDiscoveryOptions = { @@ -91,6 +100,12 @@ export type OpenAICompatibleModelDiscoveryOptions = { endpointPath?: string; /** Provider-specific response row selector when the response is not `{ data: [] }`. */ readRows?: FetchLiveProviderModelRowsParams["readRows"]; + /** Provider-owned projection when the conservative OpenAI-compatible projection is insufficient. */ + projectRows?: LiveModelRowProjection; + /** Live catalog request timeout. Defaults to 5 seconds. */ + timeoutMs?: number; + /** Successful live catalog cache lifetime. Defaults to 60 seconds. */ + ttlMs?: number; /** Provider-specific authorization headers for non-Bearer model-list APIs. */ buildRequestHeaders?: FetchLiveProviderModelRowsParams["buildRequestHeaders"]; /** @@ -443,10 +458,58 @@ function buildProviderConfig( }; } +async function projectCachedLiveModelRows( + params: BuildLiveModelProviderConfigParams & { + fallback: ModelProviderConfig; + projectRows: LiveModelRowProjection; + }, +): Promise { + const load = async (requestAuth: { apiKey?: string; discoveryApiKey?: string }) => { + const rows = await getCachedLiveProviderModelRows({ + ...params, + ...requestAuth, + cacheKeyParts: + requestAuth.apiKey === params.apiKey && + requestAuth.discoveryApiKey === params.discoveryApiKey + ? params.cacheKeyParts + : undefined, + shouldCacheRows: (candidateRows) => + params.projectRows(candidateRows, params.fallback).length > 0, + }); + return params.projectRows(rows, params.fallback); + }; + + try { + return await load({ apiKey: params.apiKey, discoveryApiKey: params.discoveryApiKey }); + } catch (error) { + if ( + params.fallbackToAnonymousOnUnauthorized && + error instanceof LiveModelCatalogHttpError && + error.status === 401 && + (params.apiKey || params.discoveryApiKey) + ) { + return await load({ apiKey: undefined, discoveryApiKey: undefined }); + } + throw error; + } +} + export async function buildLiveModelProviderConfig( params: BuildLiveModelProviderConfigParams, ): Promise { + const fallback = buildProviderConfig(params, params.models); try { + if (params.projectRows) { + const models = await projectCachedLiveModelRows({ + ...params, + fallback, + projectRows: params.projectRows, + }); + if (models.length > 0) { + return { ...fallback, models: [...models] }; + } + return fallback; + } const liveModelIds = await getCachedLiveCatalogValue({ keyParts: params.cacheKeyParts ?? [ params.providerId, @@ -467,7 +530,7 @@ export async function buildLiveModelProviderConfig { + const { models, ...providerConfig } = params.providerConfig; const fallback = { ...params.providerConfig, ...(params.apiKey ? { apiKey: params.apiKey } : {}), @@ -508,30 +572,25 @@ export async function buildOpenAICompatibleLiveModelProviderConfig(params: { 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, acceptUnknownModel).length > 0, - }); - const models = buildOpenAICompatibleLiveModels(rows, fallback, acceptUnknownModel); - 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; + return await buildLiveModelProviderConfig({ + providerId: params.providerId, + endpoint, + providerConfig, + models, + apiKey: params.apiKey, + discoveryApiKey: params.discoveryApiKey, + fetchGuard: params.fetchGuard, + signal: params.signal, + timeoutMs: params.modelDiscovery?.timeoutMs, + ttlMs: params.modelDiscovery?.ttlMs ?? 60_000, + auditContext: `${params.providerId}-model-discovery`, + readRows: params.modelDiscovery?.readRows, + buildRequestHeaders: params.modelDiscovery?.buildRequestHeaders, + projectRows: + params.modelDiscovery?.projectRows ?? + ((rows, fallbackProvider) => + buildOpenAICompatibleLiveModels(rows, fallbackProvider, acceptUnknownModel)), + }); } export async function buildOpenAICompatibleProviderCatalog(