diff --git a/extensions/cerebras/provider-catalog.ts b/extensions/cerebras/provider-catalog.ts index 41ff17dd96b9..dc6dc3d88517 100644 --- a/extensions/cerebras/provider-catalog.ts +++ b/extensions/cerebras/provider-catalog.ts @@ -1,14 +1,14 @@ /** * Cerebras model provider builder. */ +import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; -import { buildCerebrasCatalogModels, CEREBRAS_BASE_URL } from "./models.js"; +import manifest from "./openclaw.plugin.json" with { type: "json" }; /** Builds the Cerebras OpenAI-compatible model provider config. */ export function buildCerebrasProvider(): ModelProviderConfig { - return { - baseUrl: CEREBRAS_BASE_URL, - api: "openai-completions", - models: buildCerebrasCatalogModels(), - }; + return buildManifestModelProviderConfig({ + providerId: "cerebras", + catalog: manifest.modelCatalog.providers.cerebras, + }); } diff --git a/extensions/clawrouter/usage.test.ts b/extensions/clawrouter/usage.test.ts index 4da3c5e44e98..9cd33dcc223c 100644 --- a/extensions/clawrouter/usage.test.ts +++ b/extensions/clawrouter/usage.test.ts @@ -205,6 +205,23 @@ describe("ClawRouter usage", () => { expect(snapshot.billing).toEqual([{ type: "spend", amount: 0, unit: "USD" }]); }); + it("does not coerce numeric strings from the usage boundary", async () => { + const snapshot = await fetchClawRouterUsage({ + token: "proxy-key", + timeoutMs: 5000, + fetchGuard: mockFetchGuard( + Response.json({ + budget: { configured: true, limitMicros: "1000000", spentMicros: "500000" }, + usage: { summary: { requestCount: "2", totalTokens: "300", actualCostMicros: "500000" } }, + }), + ), + }); + + expect(snapshot).toMatchObject({ windows: [], plan: "Managed monthly budget" }); + expect(snapshot.billing).toBeUndefined(); + expect(snapshot.summary).toBeUndefined(); + }); + it("rejects usage JSON containing invalid UTF-8", async () => { const prefix = new TextEncoder().encode( '{"budget":{"configured":true,"windowKey":"default/test-policy/2026-', diff --git a/extensions/clawrouter/usage.ts b/extensions/clawrouter/usage.ts index b596a4b9c04b..e6425ef1dcb4 100644 --- a/extensions/clawrouter/usage.ts +++ b/extensions/clawrouter/usage.ts @@ -5,6 +5,7 @@ import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeClawRouterRootUrl } from "./provider-catalog.js"; const CLAWROUTER_USAGE_RESPONSE_MAX_BYTES = 1024 * 1024; @@ -31,10 +32,6 @@ type ClawRouterUsagePayload = { }; }; -function nonNegativeNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; -} - function formatUsd(micros: number): string { const dollars = micros / 1_000_000; return dollars < 0.01 && dollars > 0 ? `$${dollars.toFixed(4)}` : `$${dollars.toFixed(2)}`; @@ -61,9 +58,9 @@ function resolveMonthlyResetAt(windowKey: unknown): number | undefined { function buildSummary(payload: ClawRouterUsagePayload): string | undefined { const summary = payload.usage?.summary; - const requests = nonNegativeNumber(summary?.requestCount); - const tokens = nonNegativeNumber(summary?.totalTokens); - const costMicros = nonNegativeNumber(summary?.actualCostMicros); + const requests = asFiniteNumberInRange(summary?.requestCount, { min: 0 }); + const tokens = asFiniteNumberInRange(summary?.totalTokens, { min: 0 }); + const costMicros = asFiniteNumberInRange(summary?.actualCostMicros, { min: 0 }); const parts = [ requests === undefined ? undefined : `${formatCount(requests)} requests`, tokens === undefined ? undefined : `${formatCount(tokens)} tokens`, @@ -120,9 +117,9 @@ export async function fetchClawRouterUsage(params: { } const payload = await readClawRouterUsagePayload(response, params.timeoutMs); const budget = payload.budget; - const limitMicros = nonNegativeNumber(budget?.limitMicros); - const spentMicros = nonNegativeNumber(budget?.spentMicros); - const costMicros = nonNegativeNumber(payload.usage?.summary?.actualCostMicros); + const limitMicros = asFiniteNumberInRange(budget?.limitMicros, { min: 0 }); + const spentMicros = asFiniteNumberInRange(budget?.spentMicros, { min: 0 }); + const costMicros = asFiniteNumberInRange(payload.usage?.summary?.actualCostMicros, { min: 0 }); const resetAt = resolveMonthlyResetAt(budget?.windowKey); const windows = []; if (budget?.configured === true && limitMicros !== undefined && spentMicros !== undefined) { diff --git a/extensions/deepinfra/onboard.ts b/extensions/deepinfra/onboard.ts index a8319ac0624a..95f71e99b1a1 100644 --- a/extensions/deepinfra/onboard.ts +++ b/extensions/deepinfra/onboard.ts @@ -1,6 +1,6 @@ // Deepinfra setup module handles plugin onboarding behavior. import { - applyAgentDefaultModelPrimary, + createAliasOnlyPresetAppliers, type OpenClawConfig, } from "openclaw/plugin-sdk/provider-onboard"; import { DEEPINFRA_DEFAULT_MODEL_REF } from "./provider-models.js"; @@ -9,23 +9,5 @@ export function applyDeepInfraConfig( cfg: OpenClawConfig, modelRef: string = DEEPINFRA_DEFAULT_MODEL_REF, ): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[modelRef] = { - ...models[modelRef], - alias: models[modelRef]?.alias ?? "DeepInfra", - }; - - return applyAgentDefaultModelPrimary( - { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - }, - modelRef, - ); + return createAliasOnlyPresetAppliers({ modelRef, alias: "DeepInfra" }).applyConfig(cfg); } diff --git a/extensions/huggingface/models.test.ts b/extensions/huggingface/models.test.ts index 7a3c5418f076..48e1509ed0b0 100644 --- a/extensions/huggingface/models.test.ts +++ b/extensions/huggingface/models.test.ts @@ -164,7 +164,7 @@ describe("huggingface models", () => { expect(models.map((m) => m.id)).toEqual(HUGGINGFACE_MODEL_CATALOG.map((m) => m.id)); expect(cancel).toHaveBeenCalledTimes(1); expect(releaseLock).toHaveBeenCalledTimes(1); - expect(read).toHaveBeenCalledTimes(17); + expect(read).toHaveBeenCalledTimes(5); }); it("parses a valid bounded discovery response", async () => { diff --git a/extensions/huggingface/models.ts b/extensions/huggingface/models.ts index 0c6afa7031a3..58e2dab90e6f 100644 --- a/extensions/huggingface/models.ts +++ b/extensions/huggingface/models.ts @@ -1,7 +1,7 @@ // Huggingface plugin module implements models behavior. import { withTrustedEnvProxyGuardedFetchMode } from "openclaw/plugin-sdk/fetch-runtime"; import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; -import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; +import { buildLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-types"; import { fetchWithSsrFGuard, @@ -125,6 +125,52 @@ function displayNameFromApiEntry(entry: HFModelEntry, inferredName: string): str return inferredName; } +function readHuggingfaceModelRows(body: unknown): readonly unknown[] { + const data = (body as OpenAIListModelsResponse | undefined)?.data; + if (!Array.isArray(data)) { + throw new Error("Hugging Face model discovery response must contain a data array"); + } + return data; +} + +function projectHuggingfaceModels(rows: readonly unknown[]): ModelDefinitionConfig[] { + const catalogById = new Map(HUGGINGFACE_MODEL_CATALOG.map((model) => [model.id, model] as const)); + const seen = new Set(); + const models: ModelDefinitionConfig[] = []; + for (const row of rows) { + const entry = row as HFModelEntry | undefined; + const id = typeof entry?.id === "string" ? entry.id.trim() : ""; + if (!entry || !id || seen.has(id)) { + continue; + } + seen.add(id); + + const catalogEntry = catalogById.get(id); + if (catalogEntry) { + models.push(buildHuggingfaceModelDefinition(catalogEntry)); + continue; + } + + const inferred = inferredMetaFromModelId(id); + const modalities = entry?.architecture?.input_modalities; + const providers = Array.isArray(entry?.providers) ? entry.providers : []; + const providerWithContext = providers.find( + (provider) => typeof provider?.context_length === "number" && provider.context_length > 0, + ); + models.push({ + id, + name: displayNameFromApiEntry(entry, inferred.name), + reasoning: inferred.reasoning, + input: + Array.isArray(modalities) && modalities.includes("image") ? ["text", "image"] : ["text"], + cost: HUGGINGFACE_DEFAULT_COST, + contextWindow: providerWithContext?.context_length ?? HUGGINGFACE_DEFAULT_CONTEXT_WINDOW, + maxTokens: HUGGINGFACE_DEFAULT_MAX_TOKENS, + }); + } + return models; +} + export async function discoverHuggingfaceModels( apiKey: string, timeoutMs = HUGGINGFACE_DISCOVERY_TIMEOUT_MS, @@ -138,84 +184,25 @@ export async function discoverHuggingfaceModels( return HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition); } - try { - const requestTimeoutMs = resolveTimerTimeoutMs(timeoutMs, HUGGINGFACE_DISCOVERY_TIMEOUT_MS); - const { response, release } = await fetchWithSsrFGuard( - withTrustedEnvProxyGuardedFetchMode({ - url: `${HUGGINGFACE_BASE_URL}/models`, - init: { - signal: AbortSignal.timeout(requestTimeoutMs), - headers: { - Authorization: `Bearer ${trimmedKey}`, - "Content-Type": "application/json", - }, - }, - timeoutMs: requestTimeoutMs, - policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(HUGGINGFACE_BASE_URL), - auditContext: "huggingface-model-discovery", - }), - ); - try { - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - return HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition); - } - - const body = await readProviderJsonResponse( - response, - "huggingface.model-discovery", - ); - const data = body?.data; - if (!Array.isArray(data) || data.length === 0) { - return HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition); - } - - const catalogById = new Map( - HUGGINGFACE_MODEL_CATALOG.map((model) => [model.id, model] as const), - ); - const seen = new Set(); - const models: ModelDefinitionConfig[] = []; - - for (const entry of data) { - const id = typeof entry?.id === "string" ? entry.id.trim() : ""; - if (!id || seen.has(id)) { - continue; - } - seen.add(id); - - const catalogEntry = catalogById.get(id); - if (catalogEntry) { - models.push(buildHuggingfaceModelDefinition(catalogEntry)); - continue; - } - - const inferred = inferredMetaFromModelId(id); - const name = displayNameFromApiEntry(entry, inferred.name); - const modalities = entry.architecture?.input_modalities; - const input: Array<"text" | "image"> = - Array.isArray(modalities) && modalities.includes("image") ? ["text", "image"] : ["text"]; - const providers = Array.isArray(entry.providers) ? entry.providers : []; - const providerWithContext = providers.find( - (provider) => typeof provider?.context_length === "number" && provider.context_length > 0, - ); - models.push({ - id, - name, - reasoning: inferred.reasoning, - input, - cost: HUGGINGFACE_DEFAULT_COST, - contextWindow: providerWithContext?.context_length ?? HUGGINGFACE_DEFAULT_CONTEXT_WINDOW, - maxTokens: HUGGINGFACE_DEFAULT_MAX_TOKENS, - }); - } - - return models.length > 0 - ? models - : HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition); - } finally { - await release(); - } - } catch { - return HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition); - } + const requestTimeoutMs = resolveTimerTimeoutMs(timeoutMs, HUGGINGFACE_DISCOVERY_TIMEOUT_MS); + const provider = await buildLiveModelProviderConfig({ + providerId: "huggingface", + endpoint: `${HUGGINGFACE_BASE_URL}/models`, + providerConfig: { baseUrl: HUGGINGFACE_BASE_URL, api: "openai-completions" }, + models: HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition), + discoveryApiKey: trimmedKey, + signal: AbortSignal.timeout(requestTimeoutMs), + timeoutMs: requestTimeoutMs, + ttlMs: 0, + readRows: readHuggingfaceModelRows, + buildRequestHeaders: () => ({ + Authorization: `Bearer ${trimmedKey}`, + "Content-Type": "application/json", + }), + policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(HUGGINGFACE_BASE_URL), + auditContext: "huggingface-model-discovery", + fetchGuard: (params) => fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode(params)), + projectRows: projectHuggingfaceModels, + }); + return provider.models; } diff --git a/extensions/kilocode/provider-catalog.ts b/extensions/kilocode/provider-catalog.ts index 2460a62c361a..677dc80d60b5 100644 --- a/extensions/kilocode/provider-catalog.ts +++ b/extensions/kilocode/provider-catalog.ts @@ -1,28 +1,17 @@ // Kilocode provider module implements model/runtime integration. +import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import manifest from "./openclaw.plugin.json" with { type: "json" }; import { discoverKilocodeModels, KILOCODE_BASE_URL as LOCAL_KILOCODE_BASE_URL, - KILOCODE_DEFAULT_CONTEXT_WINDOW as LOCAL_KILOCODE_DEFAULT_CONTEXT_WINDOW, - KILOCODE_DEFAULT_COST as LOCAL_KILOCODE_DEFAULT_COST, - KILOCODE_DEFAULT_MAX_TOKENS as LOCAL_KILOCODE_DEFAULT_MAX_TOKENS, - KILOCODE_MODEL_CATALOG as LOCAL_KILOCODE_MODEL_CATALOG, } from "./provider-models.js"; export function buildKilocodeProvider(): ModelProviderConfig { - return { - baseUrl: LOCAL_KILOCODE_BASE_URL, - api: "openai-completions", - models: LOCAL_KILOCODE_MODEL_CATALOG.map((model) => ({ - id: model.id, - name: model.name, - reasoning: model.reasoning, - input: model.input, - cost: LOCAL_KILOCODE_DEFAULT_COST, - contextWindow: model.contextWindow ?? LOCAL_KILOCODE_DEFAULT_CONTEXT_WINDOW, - maxTokens: model.maxTokens ?? LOCAL_KILOCODE_DEFAULT_MAX_TOKENS, - })), - }; + return buildManifestModelProviderConfig({ + providerId: "kilocode", + catalog: manifest.modelCatalog.providers.kilocode, + }); } export async function buildKilocodeProviderWithDiscovery(): Promise { diff --git a/extensions/kilocode/provider-models.test.ts b/extensions/kilocode/provider-models.test.ts index f729ee768268..daa5a3068bf1 100644 --- a/extensions/kilocode/provider-models.test.ts +++ b/extensions/kilocode/provider-models.test.ts @@ -188,7 +188,9 @@ describe("discoverKilocodeModels (fetch path)", () => { const guardedFetch = requireRecord(guardedFetchParams, "guarded fetch params"); expect(guardedFetch.url).toBe(KILOCODE_MODELS_URL); const guardedInit = requireRecord(guardedFetch.init, "guarded fetch init"); - expect(guardedInit.headers).toEqual({ Accept: "application/json" }); + expect(Object.fromEntries(new Headers(guardedInit.headers as HeadersInit))).toEqual({ + accept: "application/json", + }); expect(guardedFetch.policy).toEqual({ allowedHostnames: ["api.kilo.ai"] }); expect(guardedFetch.timeoutMs).toBe(5000); expect(guardedFetch.auditContext).toBe("kilocode.model_discovery"); @@ -197,7 +199,9 @@ describe("discoverKilocodeModels (fetch path)", () => { const [fetchUrl, fetchOptions] = requireFirstMockCall(mockFetch, "mock fetch call"); expect(fetchUrl).toBe(KILOCODE_MODELS_URL); const fetchInit = requireRecord(fetchOptions, "mock fetch init"); - expect(fetchInit.headers).toEqual({ Accept: "application/json" }); + expect(Object.fromEntries(new Headers(fetchInit.headers as HeadersInit))).toEqual({ + accept: "application/json", + }); expect(models.length).toBe(2); diff --git a/extensions/kilocode/provider-models.ts b/extensions/kilocode/provider-models.ts index b730c6168405..66ed10050bde 100644 --- a/extensions/kilocode/provider-models.ts +++ b/extensions/kilocode/provider-models.ts @@ -1,18 +1,12 @@ // Kilocode provider module implements model/runtime integration. -import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http"; +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, - ssrfPolicyFromHttpBaseUrlAllowedHostname, -} from "openclaw/plugin-sdk/ssrf-runtime"; +import { ssrfPolicyFromHttpBaseUrlAllowedHostname } from "openclaw/plugin-sdk/ssrf-runtime"; import { asPositiveSafeInteger, normalizeLowercaseStringOrEmpty, } from "openclaw/plugin-sdk/string-coerce-runtime"; -const log = createSubsystemLogger("kilocode-models"); - export const KILOCODE_BASE_URL = "https://api.kilo.ai/api/gateway/"; export const KILOCODE_DEFAULT_MODEL_ID = "kilo-auto/balanced"; export const KILOCODE_DEFAULT_MODEL_REF = `kilocode/${KILOCODE_DEFAULT_MODEL_ID}`; @@ -160,74 +154,61 @@ function readGatewayModelId(value: unknown): string { return typeof id === "string" ? id.trim() : ""; } +function readGatewayModelRows(body: unknown): readonly unknown[] { + const data = (body as { data?: unknown } | undefined)?.data; + if (!Array.isArray(data)) { + throw new Error("Kilocode model list: malformed JSON response"); + } + return data; +} + +function projectKilocodeModels(rows: readonly unknown[]): ModelDefinitionConfig[] { + const models: ModelDefinitionConfig[] = []; + const discoveredIds = new Set(); + for (const rawEntry of rows) { + const id = readGatewayModelId(rawEntry); + try { + const entry = asGatewayModelEntry(rawEntry); + if ( + !id || + discoveredIds.has(id) || + entry.architecture?.output_modalities?.includes("image") + ) { + continue; + } + models.push(toModelDefinition(entry)); + discoveredIds.add(id); + } catch { + // A malformed row must not hide a later valid row with the same id. + } + } + for (const staticModel of buildStaticCatalog()) { + if (!discoveredIds.has(staticModel.id)) { + models.unshift(staticModel); + } + } + return models; +} + export async function discoverKilocodeModels(): Promise { if (process.env.NODE_ENV === "test" || process.env.VITEST) { return buildStaticCatalog(); } - try { - const { response, release } = await fetchWithSsrFGuard({ - url: KILOCODE_MODELS_URL, - init: { - headers: { Accept: "application/json" }, - }, - timeoutMs: DISCOVERY_TIMEOUT_MS, - policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(KILOCODE_BASE_URL), - auditContext: "kilocode.model_discovery", - }); - try { - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - log.warn(`Failed to discover models: HTTP ${response.status}, using static catalog`); - return buildStaticCatalog(); - } - - const data = await readProviderJsonArrayFieldResponse( - response, - "Kilocode model list", - "data", - ); - if (data.length === 0) { - log.warn("No models found from gateway API, using static catalog"); - return buildStaticCatalog(); - } - - const models: ModelDefinitionConfig[] = []; - const discoveredIds = new Set(); - - for (const rawEntry of data) { - const id = readGatewayModelId(rawEntry); - try { - const entry = asGatewayModelEntry(rawEntry); - if ( - !id || - discoveredIds.has(id) || - entry.architecture?.output_modalities?.includes("image") - ) { - continue; - } - models.push(toModelDefinition(entry)); - discoveredIds.add(id); - } catch (e) { - log.warn(`Skipping malformed model entry "${id}": ${String(e)}`); - } - } - - const staticModels = buildStaticCatalog(); - for (const staticModel of staticModels) { - if (!discoveredIds.has(staticModel.id)) { - models.unshift(staticModel); - } - } - - return models.length > 0 ? models : buildStaticCatalog(); - } finally { - await release(); - } - } catch (error) { - log.warn(`Discovery failed: ${String(error)}, using static catalog`); - return buildStaticCatalog(); - } + const provider = await buildLiveModelProviderConfig({ + providerId: "kilocode", + endpoint: KILOCODE_MODELS_URL, + providerConfig: { baseUrl: KILOCODE_BASE_URL, api: "openai-completions" }, + models: buildStaticCatalog(), + timeoutMs: DISCOVERY_TIMEOUT_MS, + ttlMs: 0, + readRows: readGatewayModelRows, + buildRequestHeaders: () => ({ Accept: "application/json" }), + policy: ssrfPolicyFromHttpBaseUrlAllowedHostname(KILOCODE_BASE_URL), + auditContext: "kilocode.model_discovery", + projectRows: projectKilocodeModels, + }); + return provider.models; } export function buildKilocodeModelDefinition(): ModelDefinitionConfig { diff --git a/extensions/kilocode/stream.ts b/extensions/kilocode/stream.ts index c0f9d4c3a814..787c7f7b1400 100644 --- a/extensions/kilocode/stream.ts +++ b/extensions/kilocode/stream.ts @@ -2,7 +2,10 @@ import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { resolveProviderRequestHeaders } from "openclaw/plugin-sdk/provider-http"; import { normalizeOpenAICompatibleReasoningPayload } from "openclaw/plugin-sdk/provider-stream-shared"; -import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord, + normalizeOptionalLowercaseString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; const KILOCODE_FEATURE_HEADER = "X-KILOCODE-FEATURE"; const KILOCODE_FEATURE_DEFAULT = "openclaw"; @@ -22,17 +25,11 @@ function normalizeKilocodeStopPayload(payloadObj: Record): void } } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function normalizeKilocodeStopAfterCaller( value: unknown, fallbackPayload: Record | undefined, ): unknown { - const replacementPayload = asRecord(value); + const replacementPayload = asOptionalRecord(value); if (replacementPayload) { normalizeKilocodeStopPayload(replacementPayload); return value; @@ -80,7 +77,7 @@ function createKilocodeStreamWrapper( ...options, headers, onPayload(payload, payloadModel) { - const payloadObj = asRecord(payload); + const payloadObj = asOptionalRecord(payload); if (payloadObj) { // Keep Kilo thinking defaults overrideable by later caller/config payload hooks. normalizeOpenAICompatibleReasoningPayload(payloadObj, thinkingLevel); diff --git a/extensions/lmstudio/src/stream.ts b/extensions/lmstudio/src/stream.ts index c1ae2463d10a..ec3edc655225 100644 --- a/extensions/lmstudio/src/stream.ts +++ b/extensions/lmstudio/src/stream.ts @@ -8,7 +8,11 @@ import { createPlainTextToolCallCompatWrapper, } from "openclaw/plugin-sdk/provider-stream-shared"; import { ssrfPolicyFromHttpBaseUrlAllowedHostname } from "openclaw/plugin-sdk/ssrf-runtime"; -import { asPositiveSafeInteger, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asPositiveSafeInteger, + asRecord, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { LMSTUDIO_PROVIDER_ID } from "./defaults.js"; import { ensureLmstudioModelLoaded } from "./models.fetch.js"; import { resolveLmstudioInferenceBase } from "./models.js"; @@ -111,14 +115,10 @@ function resolveModelHeaders(model: StreamModel): Record | undef return model.headers; } -function toRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} - function shouldPreloadLmstudioModels(value: unknown): boolean { - const providerConfig = toRecord(value); - const params = toRecord(providerConfig?.params); - return params?.preload !== false; + const providerConfig = asRecord(value); + const params = asRecord(providerConfig.params); + return params.preload !== false; } function withLmstudioUsageCompat(model: StreamModel): StreamModel { diff --git a/extensions/meta/provider-catalog.ts b/extensions/meta/provider-catalog.ts index cdf644b094a3..66cdf27fa71d 100644 --- a/extensions/meta/provider-catalog.ts +++ b/extensions/meta/provider-catalog.ts @@ -1,14 +1,14 @@ /** * Meta model provider builder. */ +import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; -import { buildMetaCatalogModels, META_BASE_URL } from "./models.js"; +import manifest from "./openclaw.plugin.json" with { type: "json" }; /** Builds the Meta OpenAI-compatible model provider config. */ export function buildMetaProvider(): ModelProviderConfig { - return { - baseUrl: META_BASE_URL, - api: "openai-responses", - models: buildMetaCatalogModels(), - }; + return buildManifestModelProviderConfig({ + providerId: "meta", + catalog: manifest.modelCatalog.providers.meta, + }); } diff --git a/extensions/ollama/src/config-compat.ts b/extensions/ollama/src/config-compat.ts index 04ff49d11607..16d61a223e45 100644 --- a/extensions/ollama/src/config-compat.ts +++ b/extensions/ollama/src/config-compat.ts @@ -1,5 +1,6 @@ // Ollama helper module supports config compat behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor"; import { OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_PROVIDER_ID } from "./defaults.js"; type LegacyConfigRule = { @@ -8,12 +9,6 @@ type LegacyConfigRule = { match: (value: unknown) => boolean; }; -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - function isRetiredOllamaCloudBaseUrl(value: unknown): value is string { if (typeof value !== "string" || !value.trim()) { return false; @@ -26,7 +21,7 @@ function isRetiredOllamaCloudBaseUrl(value: unknown): value is string { } function findRetiredOllamaCloudBaseUrl(provider: unknown): { key: "baseUrl" | "baseURL" } | null { - const record = asRecord(provider); + const record = asObjectRecord(provider); if (!record) { return null; } @@ -59,11 +54,11 @@ function migrateOllamaCloudRetiredBaseUrl(config: OpenClawConfig): { } const nextConfig = structuredClone(config); - const nextModels = asRecord(nextConfig.models) ?? {}; + const nextModels = asObjectRecord(nextConfig.models) ?? {}; nextConfig.models = nextModels as OpenClawConfig["models"]; - const nextProviders = asRecord(nextModels.providers) ?? {}; + const nextProviders = asObjectRecord(nextModels.providers) ?? {}; nextModels.providers = nextProviders; - const nextProvider = asRecord(nextProviders[OLLAMA_CLOUD_PROVIDER_ID]) ?? {}; + const nextProvider = asObjectRecord(nextProviders[OLLAMA_CLOUD_PROVIDER_ID]) ?? {}; nextProviders[OLLAMA_CLOUD_PROVIDER_ID] = nextProvider; const canonicalBaseUrl = nextProvider.baseUrl; diff --git a/extensions/openrouter/onboard.ts b/extensions/openrouter/onboard.ts index d4698baa0eba..d6290ff49495 100644 --- a/extensions/openrouter/onboard.ts +++ b/extensions/openrouter/onboard.ts @@ -1,33 +1,19 @@ // Openrouter setup module handles plugin onboarding behavior. import { - applyAgentDefaultModelPrimary, + createAliasOnlyPresetAppliers, type OpenClawConfig, } from "openclaw/plugin-sdk/provider-onboard"; export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto"; +const openrouterPresetAppliers = createAliasOnlyPresetAppliers({ + modelRef: OPENROUTER_DEFAULT_MODEL_REF, + alias: "OpenRouter", +}); export function applyOpenrouterProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[OPENROUTER_DEFAULT_MODEL_REF] = { - ...models[OPENROUTER_DEFAULT_MODEL_REF], - alias: models[OPENROUTER_DEFAULT_MODEL_REF]?.alias ?? "OpenRouter", - }; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - }; + return openrouterPresetAppliers.applyProviderConfig(cfg); } export function applyOpenrouterConfig(cfg: OpenClawConfig): OpenClawConfig { - return applyAgentDefaultModelPrimary( - applyOpenrouterProviderConfig(cfg), - OPENROUTER_DEFAULT_MODEL_REF, - ); + return openrouterPresetAppliers.applyConfig(cfg); } diff --git a/extensions/openrouter/usage.ts b/extensions/openrouter/usage.ts index ca8d1b53e54c..dd69eee3e893 100644 --- a/extensions/openrouter/usage.ts +++ b/extensions/openrouter/usage.ts @@ -1,6 +1,10 @@ import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; -import type { ProviderUsageSnapshot } from "openclaw/plugin-sdk/provider-usage"; -import { buildUsageHttpErrorSnapshot } from "openclaw/plugin-sdk/provider-usage"; +import { + buildUsageHttpErrorSnapshot, + parseProviderUsageNonNegativeNumber, + type ProviderUsageSnapshot, +} from "openclaw/plugin-sdk/provider-usage"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; const OPENROUTER_USAGE_RESPONSE_MAX_BYTES = 1024 * 1024; const OPENROUTER_API_ROOT = "https://openrouter.ai/api/v1"; @@ -33,22 +37,6 @@ type EndpointResult = type OpenRouterLimitReset = "daily" | "weekly" | "monthly"; -function nonNegativeNumber(value: unknown): number | undefined { - const parsed = - typeof value === "number" - ? value - : typeof value === "string" && value.trim() - ? Number(value) - : Number.NaN; - return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; -} - -function objectRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function resolveLimitReset(value: unknown): OpenRouterLimitReset | undefined { return value === "daily" || value === "weekly" || value === "monthly" ? value : undefined; } @@ -56,30 +44,30 @@ function resolveLimitReset(value: unknown): OpenRouterLimitReset | undefined { function resolveKeyBudget( data: OpenRouterKeyData | undefined, ): { used: number; limit: number; period?: OpenRouterLimitReset } | undefined { - const limit = nonNegativeNumber(data?.limit); + const limit = parseProviderUsageNonNegativeNumber(data?.limit); if (limit === undefined) { return undefined; } const period = resolveLimitReset(data?.limit_reset); const periodUsage = period === "daily" - ? nonNegativeNumber(data?.usage_daily) + ? parseProviderUsageNonNegativeNumber(data?.usage_daily) : period === "weekly" - ? nonNegativeNumber(data?.usage_weekly) + ? parseProviderUsageNonNegativeNumber(data?.usage_weekly) : period === "monthly" - ? nonNegativeNumber(data?.usage_monthly) - : nonNegativeNumber(data?.usage); + ? parseProviderUsageNonNegativeNumber(data?.usage_monthly) + : parseProviderUsageNonNegativeNumber(data?.usage); const byokUsage = data?.include_byok_in_limit !== true ? undefined : period === "daily" - ? nonNegativeNumber(data.byok_usage_daily) + ? parseProviderUsageNonNegativeNumber(data.byok_usage_daily) : period === "weekly" - ? nonNegativeNumber(data.byok_usage_weekly) + ? parseProviderUsageNonNegativeNumber(data.byok_usage_weekly) : period === "monthly" - ? nonNegativeNumber(data.byok_usage_monthly) - : nonNegativeNumber(data.byok_usage); - const remaining = nonNegativeNumber(data?.limit_remaining); + ? parseProviderUsageNonNegativeNumber(data.byok_usage_monthly) + : parseProviderUsageNonNegativeNumber(data.byok_usage); + const remaining = parseProviderUsageNonNegativeNumber(data?.limit_remaining); // `limit_remaining` already incorporates BYOK usage when the key is configured to count it. const usage = periodUsage === undefined && byokUsage === undefined @@ -121,8 +109,8 @@ async function fetchEndpoint(params: { return { ok: false, status: response.status }; } try { - const root = objectRecord(await readJson(response, params.timeoutMs)); - const data = objectRecord(root?.data); + const root = asOptionalRecord(await readJson(response, params.timeoutMs)); + const data = asOptionalRecord(root?.data); return data ? { ok: true, data } : { ok: false, reason: "malformed" }; } catch { return { ok: false, reason: "malformed" }; @@ -161,9 +149,9 @@ export async function fetchOpenRouterUsage(params: { const credits = creditsResult.ok ? (creditsResult.data as OpenRouterCreditsData) : undefined; const key = keyResult.ok ? (keyResult.data as OpenRouterKeyData) : undefined; - const totalCredits = nonNegativeNumber(credits?.total_credits); - const totalUsage = nonNegativeNumber(credits?.total_usage); - const keyUsage = nonNegativeNumber(key?.usage); + const totalCredits = parseProviderUsageNonNegativeNumber(credits?.total_credits); + const totalUsage = parseProviderUsageNonNegativeNumber(credits?.total_usage); + const keyUsage = parseProviderUsageNonNegativeNumber(key?.usage); const keyBudget = resolveKeyBudget(key); const windows = []; if (keyBudget) { @@ -212,9 +200,9 @@ export async function fetchOpenRouterUsage(params: { const keyLabel = typeof key?.label === "string" ? key.label.trim() : ""; const periodUsage = [ - ["today", nonNegativeNumber(key?.usage_daily)], - ["this week", nonNegativeNumber(key?.usage_weekly)], - ["this month", nonNegativeNumber(key?.usage_monthly)], + ["today", parseProviderUsageNonNegativeNumber(key?.usage_daily)], + ["this week", parseProviderUsageNonNegativeNumber(key?.usage_weekly)], + ["this month", parseProviderUsageNonNegativeNumber(key?.usage_monthly)], ] as const; const summary = periodUsage .flatMap(([period, amount]) => diff --git a/extensions/venice/usage.ts b/extensions/venice/usage.ts index 7402482c3475..59754f55f15d 100644 --- a/extensions/venice/usage.ts +++ b/extensions/venice/usage.ts @@ -1,6 +1,9 @@ import { readProviderJsonObjectResponse } from "openclaw/plugin-sdk/provider-http"; -import type { ProviderUsageSnapshot } from "openclaw/plugin-sdk/provider-usage"; -import { buildUsageHttpErrorSnapshot } from "openclaw/plugin-sdk/provider-usage"; +import { + buildUsageHttpErrorSnapshot, + parseProviderUsageNonNegativeNumber, + type ProviderUsageSnapshot, +} from "openclaw/plugin-sdk/provider-usage"; const VENICE_BALANCE_URL = "https://api.venice.ai/api/v1/billing/balance"; const VENICE_USAGE_RESPONSE_MAX_BYTES = 1024 * 1024; @@ -15,16 +18,6 @@ type VeniceBalanceResponse = { diemEpochAllocation?: unknown; }; -function nonNegativeNumber(value: unknown): number | undefined { - const parsed = - typeof value === "number" - ? value - : typeof value === "string" && value.trim() - ? Number(value) - : Number.NaN; - return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; -} - async function readPayload(response: Response, timeoutMs: number): Promise { const data = await readProviderJsonObjectResponse(response, "Venice usage", { maxBytes: VENICE_USAGE_RESPONSE_MAX_BYTES, @@ -74,9 +67,9 @@ export async function fetchVeniceUsage(params: { }; } - const diem = nonNegativeNumber(data.balances?.diem); - const usd = nonNegativeNumber(data.balances?.usd); - const allocation = nonNegativeNumber(data.diemEpochAllocation); + const diem = parseProviderUsageNonNegativeNumber(data.balances?.diem); + const usd = parseProviderUsageNonNegativeNumber(data.balances?.usd); + const allocation = parseProviderUsageNonNegativeNumber(data.diemEpochAllocation); const windows = []; if (diem !== undefined && allocation !== undefined && allocation > 0) { windows.push({ diff --git a/extensions/vercel-ai-gateway/onboard.ts b/extensions/vercel-ai-gateway/onboard.ts index 609bb9c56292..5ed09d898e85 100644 --- a/extensions/vercel-ai-gateway/onboard.ts +++ b/extensions/vercel-ai-gateway/onboard.ts @@ -1,33 +1,15 @@ // Vercel Ai Gateway setup module handles plugin onboarding behavior. import { - applyAgentDefaultModelPrimary, + createAliasOnlyPresetAppliers, type OpenClawConfig, } from "openclaw/plugin-sdk/provider-onboard"; export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.6"; - -function applyVercelAiGatewayProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF] = { - ...models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF], - alias: models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Vercel AI Gateway", - }; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - }; -} +const vercelAiGatewayPresetAppliers = createAliasOnlyPresetAppliers({ + modelRef: VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + alias: "Vercel AI Gateway", +}); export function applyVercelAiGatewayConfig(cfg: OpenClawConfig): OpenClawConfig { - return applyAgentDefaultModelPrimary( - applyVercelAiGatewayProviderConfig(cfg), - VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, - ); + return vercelAiGatewayPresetAppliers.applyConfig(cfg); } diff --git a/extensions/xai/doctor-contract-api.ts b/extensions/xai/doctor-contract-api.ts index 3ea636daf552..949b03d156be 100644 --- a/extensions/xai/doctor-contract-api.ts +++ b/extensions/xai/doctor-contract-api.ts @@ -1,5 +1,6 @@ // Xai doctor contract repairs plugin-owned model configuration. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor"; import { isLegacyXaiBuiltinModel } from "./model-definitions.js"; type LegacyConfigRule = { @@ -56,16 +57,10 @@ const PLUGIN_MODEL_MIGRATIONS: PluginModelMigration[] = [ ]; const XAI_STT_MODEL_LIST_PATHS = [["tools", "media", "models"]] as const; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readPath(root: unknown, path: readonly string[]): unknown { let current = root; for (const segment of path) { - current = asRecord(current)?.[segment]; + current = asObjectRecord(current)?.[segment]; if (current === undefined) { return undefined; } @@ -74,7 +69,7 @@ function readPath(root: unknown, path: readonly string[]): unknown { } function isRetiredToolModel(value: unknown, retiredModels: ReadonlySet): boolean { - const model = asRecord(value)?.model; + const model = asObjectRecord(value)?.model; return typeof model === "string" && retiredModels.has(model.trim().toLowerCase()); } @@ -83,7 +78,7 @@ function hasLegacyBuiltinCatalogRows(value: unknown): boolean { } function isLegacyXaiSttEntry(value: unknown): boolean { - const entry = asRecord(value); + const entry = asObjectRecord(value); if (!entry || (entry.type !== undefined && entry.type !== "provider")) { return false; } @@ -133,7 +128,7 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): if (next === cfg) { next = structuredClone(cfg); } - const target = asRecord(readPath(next, migration.path)); + const target = asObjectRecord(readPath(next, migration.path)); if (!target) { continue; } @@ -160,7 +155,7 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): if (!isLegacyXaiSttEntry(entry)) { continue; } - delete asRecord(entry)?.model; + delete asObjectRecord(entry)?.model; removed += 1; } changes.push( @@ -174,7 +169,7 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): if (next === cfg) { next = structuredClone(cfg); } - const provider = asRecord(readPath(next, ["models", "providers", "xai"])); + const provider = asObjectRecord(readPath(next, ["models", "providers", "xai"])); const models = provider?.models; if (provider && Array.isArray(models)) { const retained = models.filter((model) => !isLegacyXaiBuiltinModel(model)); diff --git a/extensions/xai/model-definitions.ts b/extensions/xai/model-definitions.ts index 9f09dd292f09..163a472bb851 100644 --- a/extensions/xai/model-definitions.ts +++ b/extensions/xai/model-definitions.ts @@ -1,6 +1,9 @@ // Xai plugin module implements model definitions behavior. import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; -import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord, + normalizeOptionalLowercaseString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeXaiModelId } from "./model-id.js"; export const XAI_BASE_URL = "https://api.x.ai/v1"; @@ -253,12 +256,6 @@ const LEGACY_MODEL_KEYS = new Set([ ]); const LEGACY_COST_KEYS = new Set(["input", "output", "cacheRead", "cacheWrite"]); -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function normalizeXaiCatalogModelId(modelId: string): string { const lower = normalizeOptionalLowercaseString(modelId) ?? ""; const unprefixed = lower.startsWith("xai/") ? lower.slice("xai/".length) : lower; @@ -266,12 +263,12 @@ function normalizeXaiCatalogModelId(modelId: string): string { } export function isLegacyXaiBuiltinModel(model: unknown): boolean { - const record = asRecord(model); + const record = asOptionalRecord(model); const id = normalizeOptionalLowercaseString(record?.id); const signature = id ? LEGACY_XAI_BUILTIN_SIGNATURES[id as keyof typeof LEGACY_XAI_BUILTIN_SIGNATURES] : undefined; - const cost = asRecord(record?.cost); + const cost = asOptionalRecord(record?.cost); if (!record || !signature || !cost) { return false; } diff --git a/src/infra/provider-usage.admin.ts b/src/infra/provider-usage.admin.ts index 371396054de9..23b675e7db4d 100644 --- a/src/infra/provider-usage.admin.ts +++ b/src/infra/provider-usage.admin.ts @@ -63,6 +63,11 @@ export function parseProviderUsageNumber(value: unknown): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } +export function parseProviderUsageNonNegativeNumber(value: unknown): number | undefined { + const parsed = parseProviderUsageNumber(value); + return parsed !== undefined && parsed >= 0 ? parsed : undefined; +} + export function parseProviderUsageNonNegativeInteger(value: unknown): number { const parsed = parseProviderUsageNumber(value); return parsed === undefined ? 0 : Math.max(0, Math.trunc(parsed)); diff --git a/src/plugin-sdk/provider-onboard.test.ts b/src/plugin-sdk/provider-onboard.test.ts new file mode 100644 index 000000000000..664f0c134708 --- /dev/null +++ b/src/plugin-sdk/provider-onboard.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + createAliasOnlyPresetAppliers, + resolveAgentModelPrimaryValue, + type OpenClawConfig, +} from "./provider-onboard.js"; + +describe("createAliasOnlyPresetAppliers", () => { + const modelRef = "example/default"; + const appliers = createAliasOnlyPresetAppliers({ modelRef, alias: "Example" }); + + it("adds only the alias entry in provider-only mode", () => { + const cfg: OpenClawConfig = { + models: { mode: "merge", providers: {} }, + agents: { defaults: { models: { "other/model": { alias: "Other" } } } }, + }; + + const result = appliers.applyProviderConfig(cfg); + + expect(result.models).toBe(cfg.models); + expect(result.agents?.defaults?.model).toBeUndefined(); + expect(result.agents?.defaults?.models).toEqual({ + "other/model": { alias: "Other" }, + [modelRef]: { alias: "Example" }, + }); + }); + + it("preserves entry fields and alias while replacing the primary", () => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "old/model", fallbacks: ["fallback/model"] }, + models: { [modelRef]: { alias: "Custom", params: { temperature: 0.2 } } }, + }, + }, + }; + + const result = appliers.applyConfig(cfg); + + expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe(modelRef); + expect(result.agents?.defaults?.model).toEqual({ + primary: modelRef, + fallbacks: ["fallback/model"], + }); + expect(result.agents?.defaults?.models?.[modelRef]).toEqual({ + alias: "Custom", + params: { temperature: 0.2 }, + }); + }); +}); diff --git a/src/plugin-sdk/provider-onboard.ts b/src/plugin-sdk/provider-onboard.ts index e4a48e86391b..0396d2595304 100644 --- a/src/plugin-sdk/provider-onboard.ts +++ b/src/plugin-sdk/provider-onboard.ts @@ -266,6 +266,31 @@ export function withAgentModelAliases( return next; } +/** Build alias-only onboarding appliers without mutating provider catalog config. */ +export function createAliasOnlyPresetAppliers(params: { + modelRef: string; + alias: string; +}): ProviderOnboardPresetAppliers<[]> { + const applyProviderConfig = (cfg: OpenClawConfig): OpenClawConfig => { + const models = { ...cfg.agents?.defaults?.models }; + models[params.modelRef] = { + ...models[params.modelRef], + alias: models[params.modelRef]?.alias ?? params.alias, + }; + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { ...cfg.agents?.defaults, models }, + }, + }; + }; + return { + applyProviderConfig, + applyConfig: (cfg) => applyAgentDefaultModelPrimary(applyProviderConfig(cfg), params.modelRef), + }; +} + function isMergeableProviderConfig( value: ModelProviderConfig | undefined, ): value is ModelProviderConfig { diff --git a/src/plugin-sdk/provider-usage.ts b/src/plugin-sdk/provider-usage.ts index e1f04007034e..5cd283f0a8ae 100644 --- a/src/plugin-sdk/provider-usage.ts +++ b/src/plugin-sdk/provider-usage.ts @@ -30,6 +30,7 @@ export { encodeProviderUsageAdminToken, fetchProviderUsagePages, parseProviderUsageNonNegativeInteger, + parseProviderUsageNonNegativeNumber, parseProviderUsageNumber, resolveProviderUsageDailyPeriod, resolveProviderUsageDisplayName,