refactor(providers): consolidate catalog setup helpers (#117824)

* refactor(providers): consolidate catalog setup helpers

* fix(huggingface): narrow discovered model entries
This commit is contained in:
Peter Steinberger
2026-08-01 22:17:52 -07:00
committed by GitHub
parent 72f1e2f97c
commit 35a1777f68
23 changed files with 329 additions and 358 deletions
+6 -6
View File
@@ -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,
});
}
+17
View File
@@ -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-',
+7 -10
View File
@@ -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) {
+2 -20
View File
@@ -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);
}
+1 -1
View File
@@ -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 () => {
+68 -81
View File
@@ -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<string>();
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<OpenAIListModelsResponse>(
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<string>();
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;
}
+6 -17
View File
@@ -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<ModelProviderConfig> {
+6 -2
View File
@@ -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);
+52 -71
View File
@@ -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<string>();
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<ModelDefinitionConfig[]> {
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<string>();
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 {
+6 -9
View File
@@ -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<string, unknown>): void
}
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function normalizeKilocodeStopAfterCaller(
value: unknown,
fallbackPayload: Record<string, unknown> | 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);
+8 -8
View File
@@ -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<string, string> | undef
return model.headers;
}
function toRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : 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 {
+6 -6
View File
@@ -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,
});
}
+5 -10
View File
@@ -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<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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;
+7 -21
View File
@@ -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);
}
+24 -36
View File
@@ -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<string, unknown> | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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]) =>
+8 -15
View File
@@ -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<VeniceBalanceResponse> {
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({
+6 -24
View File
@@ -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);
}
+7 -12
View File
@@ -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<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<string>): 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));
+6 -9
View File
@@ -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<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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;
}
+5
View File
@@ -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));
+50
View File
@@ -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 },
});
});
});
+25
View File
@@ -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 {
+1
View File
@@ -30,6 +30,7 @@ export {
encodeProviderUsageAdminToken,
fetchProviderUsagePages,
parseProviderUsageNonNegativeInteger,
parseProviderUsageNonNegativeNumber,
parseProviderUsageNumber,
resolveProviderUsageDailyPeriod,
resolveProviderUsageDisplayName,