mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
52c11daed6
commit
603f839058
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
@@ -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,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,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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user