mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(providers): consolidate catalog setup helpers (#117824)
* refactor(providers): consolidate catalog setup helpers * fix(huggingface): narrow discovered model entries
This commit is contained in:
committed by
GitHub
parent
72f1e2f97c
commit
35a1777f68
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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-',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]) =>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -30,6 +30,7 @@ export {
|
||||
encodeProviderUsageAdminToken,
|
||||
fetchProviderUsagePages,
|
||||
parseProviderUsageNonNegativeInteger,
|
||||
parseProviderUsageNonNegativeNumber,
|
||||
parseProviderUsageNumber,
|
||||
resolveProviderUsageDailyPeriod,
|
||||
resolveProviderUsageDisplayName,
|
||||
|
||||
Reference in New Issue
Block a user