Agents: opt-in configured-model merge for non-native providers

This commit is contained in:
Gustavo Madeira Santana
2026-02-23 21:07:51 -05:00
parent 7239d19c72
commit f5a7e1a385
2 changed files with 205 additions and 0 deletions

View File

@@ -103,4 +103,124 @@ describe("loadModelCatalog", () => {
expect(spark?.name).toBe("gpt-5.3-codex-spark");
expect(spark?.reasoning).toBe(true);
});
it("merges configured models for opted-in non-pi-native providers", async () => {
__setModelCatalogImportForTest(
async () =>
({
AuthStorage: class {},
ModelRegistry: class {
getAll() {
return [{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }];
}
},
}) as unknown as PiSdkModule,
);
const result = await loadModelCatalog({
config: {
models: {
providers: {
kilocode: {
models: [
{
id: "google/gemini-3-pro-preview",
name: "Gemini 3 Pro Preview",
input: ["text", "image"],
reasoning: true,
contextWindow: 1048576,
},
],
},
},
},
} as OpenClawConfig,
});
expect(result).toContainEqual(
expect.objectContaining({
provider: "kilocode",
id: "google/gemini-3-pro-preview",
name: "Gemini 3 Pro Preview",
}),
);
});
it("does not merge configured models for providers that are not opted in", async () => {
__setModelCatalogImportForTest(
async () =>
({
AuthStorage: class {},
ModelRegistry: class {
getAll() {
return [{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }];
}
},
}) as unknown as PiSdkModule,
);
const result = await loadModelCatalog({
config: {
models: {
providers: {
qianfan: {
models: [
{
id: "deepseek-v3.2",
name: "DEEPSEEK V3.2",
},
],
},
},
},
} as OpenClawConfig,
});
expect(
result.some((entry) => entry.provider === "qianfan" && entry.id === "deepseek-v3.2"),
).toBe(false);
});
it("does not duplicate opted-in configured models already present in ModelRegistry", async () => {
__setModelCatalogImportForTest(
async () =>
({
AuthStorage: class {},
ModelRegistry: class {
getAll() {
return [
{
id: "anthropic/claude-opus-4.6",
provider: "kilocode",
name: "Claude Opus 4.6",
},
];
}
},
}) as unknown as PiSdkModule,
);
const result = await loadModelCatalog({
config: {
models: {
providers: {
kilocode: {
models: [
{
id: "anthropic/claude-opus-4.6",
name: "Configured Claude Opus 4.6",
},
],
},
},
},
} as OpenClawConfig,
});
const matches = result.filter(
(entry) => entry.provider === "kilocode" && entry.id === "anthropic/claude-opus-4.6",
);
expect(matches).toHaveLength(1);
expect(matches[0]?.name).toBe("Claude Opus 4.6");
});
});

View File

@@ -33,6 +33,7 @@ let importPiSdk = defaultImportPiSdk;
const CODEX_PROVIDER = "openai-codex";
const OPENAI_CODEX_GPT53_MODEL_ID = "gpt-5.3-codex";
const OPENAI_CODEX_GPT53_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
const NON_PI_NATIVE_MODEL_PROVIDERS = new Set(["kilocode"]);
function applyOpenAICodexSparkFallback(models: ModelCatalogEntry[]): void {
const hasSpark = models.some(
@@ -59,6 +60,89 @@ function applyOpenAICodexSparkFallback(models: ModelCatalogEntry[]): void {
});
}
function normalizeConfiguredModelInput(input: unknown): Array<"text" | "image"> | undefined {
if (!Array.isArray(input)) {
return undefined;
}
const normalized = input.filter(
(item): item is "text" | "image" => item === "text" || item === "image",
);
return normalized.length > 0 ? normalized : undefined;
}
function readConfiguredOptInProviderModels(config: OpenClawConfig): ModelCatalogEntry[] {
const providers = config.models?.providers;
if (!providers || typeof providers !== "object") {
return [];
}
const out: ModelCatalogEntry[] = [];
for (const [providerRaw, providerValue] of Object.entries(providers)) {
const provider = providerRaw.toLowerCase().trim();
if (!NON_PI_NATIVE_MODEL_PROVIDERS.has(provider)) {
continue;
}
if (!providerValue || typeof providerValue !== "object") {
continue;
}
const configuredModels = (providerValue as { models?: unknown }).models;
if (!Array.isArray(configuredModels)) {
continue;
}
for (const configuredModel of configuredModels) {
if (!configuredModel || typeof configuredModel !== "object") {
continue;
}
const idRaw = (configuredModel as { id?: unknown }).id;
if (typeof idRaw !== "string") {
continue;
}
const id = idRaw.trim();
if (!id) {
continue;
}
const rawName = (configuredModel as { name?: unknown }).name;
const name = (typeof rawName === "string" ? rawName : id).trim() || id;
const contextWindowRaw = (configuredModel as { contextWindow?: unknown }).contextWindow;
const contextWindow =
typeof contextWindowRaw === "number" && contextWindowRaw > 0 ? contextWindowRaw : undefined;
const reasoningRaw = (configuredModel as { reasoning?: unknown }).reasoning;
const reasoning = typeof reasoningRaw === "boolean" ? reasoningRaw : undefined;
const input = normalizeConfiguredModelInput((configuredModel as { input?: unknown }).input);
out.push({ id, name, provider, contextWindow, reasoning, input });
}
}
return out;
}
function mergeConfiguredOptInProviderModels(params: {
config: OpenClawConfig;
models: ModelCatalogEntry[];
}): void {
const configured = readConfiguredOptInProviderModels(params.config);
if (configured.length === 0) {
return;
}
const seen = new Set(
params.models.map(
(entry) => `${entry.provider.toLowerCase().trim()}::${entry.id.toLowerCase().trim()}`,
),
);
for (const entry of configured) {
const key = `${entry.provider.toLowerCase().trim()}::${entry.id.toLowerCase().trim()}`;
if (seen.has(key)) {
continue;
}
params.models.push(entry);
seen.add(key);
}
}
export function resetModelCatalogCacheForTest() {
modelCatalogPromise = null;
hasLoggedModelCatalogError = false;
@@ -142,6 +226,7 @@ export async function loadModelCatalog(params?: {
const input = Array.isArray(entry?.input) ? entry.input : undefined;
models.push({ id, name, provider, contextWindow, reasoning, input });
}
mergeConfiguredOptInProviderModels({ config: cfg, models });
applyOpenAICodexSparkFallback(models);
if (models.length === 0) {