style: fix formatting and improve model selection logic with tests

This commit is contained in:
senoldogann 2026-01-25 14:36:36 +02:00 committed by Peter Steinberger
parent cdd82b4fa4
commit f552055cef
2 changed files with 148 additions and 255 deletions

View File

@ -1,252 +1,139 @@
import { describe, expect, it } from "vitest"; import { describe, it, expect, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_PROVIDER } from "./defaults.js";
import { import {
buildAllowedModelSet,
modelKey,
parseModelRef, parseModelRef,
resolveAllowedModelRef, resolveModelRefFromString,
resolveHooksGmailModel, resolveConfiguredModelRef,
buildModelAliasIndex,
normalizeProviderId,
modelKey,
} from "./model-selection.js"; } from "./model-selection.js";
import type { ClawdbotConfig } from "../config/config.js";
const catalog = [ describe("model-selection", () => {
{ describe("normalizeProviderId", () => {
provider: "openai", it("should normalize provider names", () => {
id: "gpt-4", expect(normalizeProviderId("Anthropic")).toBe("anthropic");
name: "GPT-4", expect(normalizeProviderId("Z.ai")).toBe("zai");
}, expect(normalizeProviderId("z-ai")).toBe("zai");
]; expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode");
expect(normalizeProviderId("qwen")).toBe("qwen-portal");
describe("buildAllowedModelSet", () => {
it("always allows the configured default model", () => {
const cfg = {
agents: {
defaults: {
models: {
"openai/gpt-4": { alias: "gpt4" },
},
},
},
} as ClawdbotConfig;
const allowed = buildAllowedModelSet({
cfg,
catalog,
defaultProvider: "claude-cli",
defaultModel: "opus-4.5",
});
expect(allowed.allowAny).toBe(false);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true);
});
it("includes the default model when no allowlist is set", () => {
const cfg = {
agents: { defaults: {} },
} as ClawdbotConfig;
const allowed = buildAllowedModelSet({
cfg,
catalog,
defaultProvider: "claude-cli",
defaultModel: "opus-4.5",
});
expect(allowed.allowAny).toBe(true);
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true);
});
it("allows explicit custom providers from models.providers", () => {
const cfg = {
agents: {
defaults: {
models: {
"moonshot/kimi-k2-0905-preview": { alias: "kimi" },
},
},
},
models: {
mode: "merge",
providers: {
moonshot: {
baseUrl: "https://api.moonshot.ai/v1",
apiKey: "x",
api: "openai-completions",
models: [{ id: "kimi-k2-0905-preview", name: "Kimi" }],
},
},
},
} as ClawdbotConfig;
const allowed = buildAllowedModelSet({
cfg,
catalog: [],
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
});
expect(allowed.allowAny).toBe(false);
expect(allowed.allowedKeys.has(modelKey("moonshot", "kimi-k2-0905-preview"))).toBe(true);
}); });
}); });
describe("parseModelRef", () => { describe("parseModelRef", () => {
it("normalizes anthropic/opus-4.5 to claude-opus-4-5", () => { it("should parse full model refs", () => {
const ref = parseModelRef("anthropic/opus-4.5", "anthropic"); expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({
expect(ref).toEqual({
provider: "anthropic", provider: "anthropic",
model: "claude-opus-4-5", model: "claude-3-5-sonnet",
}); });
}); });
it("normalizes google gemini 3 models to preview ids", () => { it("should use default provider if none specified", () => {
expect(parseModelRef("google/gemini-3-pro", "anthropic")).toEqual({ expect(parseModelRef("claude-3-5-sonnet", "anthropic")).toEqual({
provider: "google", provider: "anthropic",
model: "gemini-3-pro-preview", model: "claude-3-5-sonnet",
});
expect(parseModelRef("google/gemini-3-flash", "anthropic")).toEqual({
provider: "google",
model: "gemini-3-flash-preview",
}); });
}); });
it("normalizes default-provider google models", () => { it("should return null for empty strings", () => {
expect(parseModelRef("gemini-3-pro", "google")).toEqual({ expect(parseModelRef("", "anthropic")).toBeNull();
provider: "google", expect(parseModelRef(" ", "anthropic")).toBeNull();
model: "gemini-3-pro-preview",
}); });
it("should handle invalid slash usage", () => {
expect(parseModelRef("/", "anthropic")).toBeNull();
expect(parseModelRef("anthropic/", "anthropic")).toBeNull();
expect(parseModelRef("/model", "anthropic")).toBeNull();
}); });
}); });
describe("resolveHooksGmailModel", () => { describe("buildModelAliasIndex", () => {
it("returns null when hooks.gmail.model is not set", () => { it("should build alias index from config", () => {
const cfg = {} satisfies ClawdbotConfig; const cfg: Partial<ClawdbotConfig> = {
const result = resolveHooksGmailModel({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
expect(result).toBeNull();
});
it("returns null when hooks.gmail.model is empty", () => {
const cfg = {
hooks: { gmail: { model: "" } },
} satisfies ClawdbotConfig;
const result = resolveHooksGmailModel({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
expect(result).toBeNull();
});
it("parses provider/model from hooks.gmail.model", () => {
const cfg = {
hooks: { gmail: { model: "openrouter/meta-llama/llama-3.3-70b:free" } },
} satisfies ClawdbotConfig;
const result = resolveHooksGmailModel({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
expect(result).toEqual({
provider: "openrouter",
model: "meta-llama/llama-3.3-70b:free",
});
});
it("resolves alias from agent.models", () => {
const cfg = {
agents: { agents: {
defaults: { defaults: {
models: { models: {
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" }, "anthropic/claude-3-5-sonnet": { alias: "fast" },
"openai/gpt-4o": { alias: "smart" },
}, },
}, },
}, },
hooks: { gmail: { model: "Sonnet" } }, };
} satisfies ClawdbotConfig;
const result = resolveHooksGmailModel({
cfg,
defaultProvider: DEFAULT_PROVIDER,
});
expect(result).toEqual({
provider: "anthropic",
model: "claude-sonnet-4-1",
});
});
it("uses default provider when model omits provider", () => { const index = buildModelAliasIndex({
const cfg = { cfg: cfg as ClawdbotConfig,
hooks: { gmail: { model: "claude-haiku-3-5" } },
} satisfies ClawdbotConfig;
const result = resolveHooksGmailModel({
cfg,
defaultProvider: "anthropic", defaultProvider: "anthropic",
}); });
expect(result).toEqual({
expect(index.byAlias.get("fast")?.ref).toEqual({
provider: "anthropic", provider: "anthropic",
model: "claude-haiku-3-5", model: "claude-3-5-sonnet",
}); });
expect(index.byAlias.get("smart")?.ref).toEqual({ provider: "openai", model: "gpt-4o" });
expect(index.byKey.get(modelKey("anthropic", "claude-3-5-sonnet"))).toEqual(["fast"]);
}); });
}); });
describe("resolveAllowedModelRef", () => { describe("resolveModelRefFromString", () => {
it("resolves aliases when allowed", () => { it("should resolve from string with alias", () => {
const cfg = { const index = {
agents: { byAlias: new Map([
defaults: { ["fast", { alias: "fast", ref: { provider: "anthropic", model: "sonnet" } }],
models: { ]),
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" }, byKey: new Map(),
}, };
},
}, const resolved = resolveModelRefFromString({
} satisfies ClawdbotConfig; raw: "fast",
const resolved = resolveAllowedModelRef({ defaultProvider: "openai",
cfg, aliasIndex: index,
catalog: [ });
{
provider: "anthropic", expect(resolved?.ref).toEqual({ provider: "anthropic", model: "sonnet" });
id: "claude-sonnet-4-1", expect(resolved?.alias).toBe("fast");
name: "Sonnet", });
},
], it("should resolve direct ref if no alias match", () => {
raw: "Sonnet", const resolved = resolveModelRefFromString({
raw: "openai/gpt-4",
defaultProvider: "anthropic", defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
}); });
expect("error" in resolved).toBe(false); expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-4" });
if ("ref" in resolved) {
expect(resolved.ref).toEqual({
provider: "anthropic",
model: "claude-sonnet-4-1",
}); });
}
}); });
it("rejects disallowed models", () => { describe("resolveConfiguredModelRef", () => {
const cfg = { it("should fall back to anthropic and warn if provider is missing for non-alias", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const cfg: Partial<ClawdbotConfig> = {
agents: { agents: {
defaults: { defaults: {
models: { model: "claude-3-5-sonnet",
"openai/gpt-4": { alias: "GPT4" },
}, },
}, },
}, };
} satisfies ClawdbotConfig;
const resolved = resolveAllowedModelRef({ const result = resolveConfiguredModelRef({
cfg, cfg: cfg as ClawdbotConfig,
catalog: [ defaultProvider: "google",
{ provider: "openai", id: "gpt-4", name: "GPT-4" }, defaultModel: "gemini-pro",
{ provider: "anthropic", id: "claude-sonnet-4-1", name: "Sonnet" }, });
],
raw: "anthropic/claude-sonnet-4-1", expect(result).toEqual({ provider: "anthropic", model: "claude-3-5-sonnet" });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Falling back to "anthropic/claude-3-5-sonnet"'),
);
warnSpy.mockRestore();
});
it("should use default provider/model if config is empty", () => {
const cfg: Partial<ClawdbotConfig> = {};
const result = resolveConfiguredModelRef({
cfg: cfg as ClawdbotConfig,
defaultProvider: "openai", defaultProvider: "openai",
defaultModel: "gpt-4", defaultModel: "gpt-4",
}); });
expect(resolved).toEqual({ expect(result).toEqual({ provider: "openai", model: "gpt-4" });
error: "model not allowed: anthropic/claude-sonnet-4-1",
}); });
}); });
}); });

View File

@ -131,12 +131,10 @@ export function resolveConfiguredModelRef(params: {
cfg: params.cfg, cfg: params.cfg,
defaultProvider: params.defaultProvider, defaultProvider: params.defaultProvider,
}); });
const resolved = resolveModelRefFromString({ if (!trimmed.includes("/")) {
raw: trimmed, const aliasKey = normalizeAliasKey(trimmed);
defaultProvider: params.defaultProvider, const aliasMatch = aliasIndex.byAlias.get(aliasKey);
aliasIndex, if (aliasMatch) return aliasMatch.ref;
});
if (resolved) return resolved.ref;
// Default to anthropic if no provider is specified, but warn as this is deprecated. // Default to anthropic if no provider is specified, but warn as this is deprecated.
console.warn( console.warn(
@ -144,6 +142,14 @@ export function resolveConfiguredModelRef(params: {
); );
return { provider: "anthropic", model: trimmed }; return { provider: "anthropic", model: trimmed };
} }
const resolved = resolveModelRefFromString({
raw: trimmed,
defaultProvider: params.defaultProvider,
aliasIndex,
});
if (resolved) return resolved.ref;
}
return { provider: params.defaultProvider, model: params.defaultModel }; return { provider: params.defaultProvider, model: params.defaultModel };
} }