refactor(providers): collapse live model discovery onto shared projection hook (#113903)

* feat(plugin-sdk): add live catalog row projection

* refactor(providers): share live catalog projection

* fix(venice): keep live projection internal
This commit is contained in:
Peter Steinberger
2026-07-25 16:17:27 -07:00
committed by GitHub
parent 52c11daed6
commit 603f839058
20 changed files with 518 additions and 578 deletions
+8 -36
View File
@@ -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
+1 -1
View File
@@ -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 |
+1 -3
View File
@@ -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";
+20 -5
View File
@@ -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() }),
+25 -15
View File
@@ -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",
+1 -52
View File
@@ -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<string, string | undefined>;
forceLive?: boolean;
fetchGuard?: LiveModelCatalogFetchGuard;
signal?: AbortSignal;
} = {},
): Promise<ModelDefinitionConfig[]> {
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();
+1 -10
View File
@@ -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<ModelProviderConfig> {
return {
baseUrl: BASETEN_BASE_URL,
api: "openai-completions",
models: await discoverBasetenModels({ discoveryApiKey }),
};
}
+53 -89
View File
@@ -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<readonly unknown[]> {
return await getCachedLiveProviderModelRows({
function projectChutesModels(rows: readonly unknown[]): ModelDefinitionConfig[] {
const seen = new Set<string>();
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<ModelDefinitionConfig[]> {
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<readonly unkn
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(CHUTES_BASE_URL),
auditContext: "chutes-model-discovery",
fetchGuard: (params) => 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<ModelDefinitionConfig[]> {
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<string>();
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;
}
+30 -49
View File
@@ -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<string>();
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<string[]> {
const rows = await getCachedLiveProviderModelRows({
): Promise<ModelProviderConfig> {
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<string>();
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<ModelProviderConfig> {
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[] {
+28 -33
View File
@@ -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<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;
}
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));
},
});
}
+1 -7
View File
@@ -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";
+4 -3
View File
@@ -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,
+35 -15
View File
@@ -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));
});
+70 -170
View File
@@ -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<unknown>();
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<ModelDefinitionConfig[]> {
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<string, ModelDefinitionConfig>(
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;
+3 -4
View File
@@ -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<ModelProviderConfig> {
const models = await discoverVeniceModels();
export function buildStaticVeniceProvider(): ModelProviderConfig {
return {
baseUrl: VENICE_BASE_URL,
api: "openai-completions",
models,
models: structuredClone(VENICE_MODEL_CATALOG),
};
}
+20 -28
View File
@@ -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<ModelDefinitionCo
return getStaticVercelAiGatewayModelCatalog();
}
try {
const data = await getCachedLiveProviderModelRows({
providerId: VERCEL_AI_GATEWAY_PROVIDER_ID,
endpoint: `${VERCEL_AI_GATEWAY_BASE_URL}/v1/models`,
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)),
});
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;
}
+26 -33
View File
@@ -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<ModelProviderConfig> {
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)),
});
}
+3
View File
@@ -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(
@@ -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<LiveModelCatalogFetchGuard> = 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<LiveModelCatalogFetchGuard> = 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);
});
});
+84 -25
View File
@@ -53,6 +53,11 @@ export type CachedLiveProviderModelRowsParams = FetchLiveProviderModelRowsParams
shouldCacheRows?: (rows: readonly unknown[]) => boolean;
};
export type LiveModelRowProjection<T extends ModelDefinitionConfig = ModelDefinitionConfig> = (
rows: readonly unknown[],
fallback: ModelProviderConfig,
) => readonly T[];
// Live model catalogs are fetched at runtime from provider-controlled endpoints,
// so the success body is untrusted just like the error body. A faulty or hostile
// provider can stream an unbounded JSON document; reading it without a ceiling
@@ -79,6 +84,10 @@ export type BuildLiveModelProviderConfigParams<T extends ModelDefinitionConfig>
models: readonly T[];
ttlMs?: number;
cacheKeyParts?: readonly unknown[];
/** Provider-owned projection for catalogs that publish richer metadata than model ids. */
projectRows?: LiveModelRowProjection<T>;
/** 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<T extends ModelDefinitionConfig>(
};
}
async function projectCachedLiveModelRows<T extends ModelDefinitionConfig>(
params: BuildLiveModelProviderConfigParams<T> & {
fallback: ModelProviderConfig;
projectRows: LiveModelRowProjection<T>;
},
): Promise<readonly T[]> {
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<T extends ModelDefinitionConfig>(
params: BuildLiveModelProviderConfigParams<T>,
): Promise<ModelProviderConfig> {
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<T extends ModelDefinitionConf
// Live model catalogs are advisory. Keep provider-owned static rows visible
// when discovery is unavailable or the provider returns an unexpected body.
}
return buildProviderConfig(params, params.models);
return fallback;
}
function resolveLiveModelDiscoveryEndpoint(baseUrl: string, endpointPath: string): string {
@@ -494,6 +557,7 @@ export async function buildOpenAICompatibleLiveModelProviderConfig(params: {
fetchGuard?: LiveModelCatalogFetchGuard;
signal?: AbortSignal;
}): Promise<ModelProviderConfig> {
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(