mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-01 11:23:31 +00:00
* refactor: extract agent core package Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts. * refactor: extract shared llm runtime Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout. * refactor: remove pi runtime internals Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code. * refactor: tighten agent session runtime Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts. * refactor: remove static model and pi auth paths Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities. * refactor: remove legacy provider compat paths * docs: remove agent parity notes * fix: skip provider wildcard metadata parsing * refactor: share session extension sdk loading * refactor: inline acpx proxy error formatter * refactor: fold edit recovery into edit tool * fix: accept extension batch separator * test: align startup provider plugin expectations * fix: restore provider-scoped release discovery * test: align static asset packaging expectations * fix: run static provider catalogs during scoped discovery * fix: add provider entry catalogs for scoped live discovery * fix: load lightweight provider catalog entries * fix: refresh provider-scoped plugin metadata * fix: keep provider catalog entries on release live path * fix: keep static manifest models in release live checks * fix: harden release model discovery * fix: reduce OpenAI live cache probe reasoning * fix: disable OpenAI cache probe reasoning * ci: extend OpenAI gateway live timeout * fix: extend live gateway model budget * fix: stabilize release validation regressions * fix: honor provider aliases in model rows * fix: stabilize release validation lanes * fix: stabilize release memory qa * ci: stabilize release validation lanes * ci: prefer ipv4 for live docker node calls * fix: restore shared tool-call stream wrapper * ci: remove legacy pi test shard alias * fix: clean up embedded agent test drift * fix: stabilize runtime alias status * fix: clean up embedded agent ci drift * fix: restore release ci invariants * fix: clean up post-rebase runtime drift * fix: restore release ci checks * fix: restore release ci after rebase * fix: remove stale pi runtime path * test: align compaction runtime expectations * test: update plugin prerelease expectations * fix: handle claude live tool approvals * fix: stabilize release validation gates * fix: finish agent runtime import * test: finish post-rebase agent runtime mocks * fix: keep codex compaction native * fix: stabilize codex app-server hook tests * test: isolate codex diagnostic active run * test: remove codex diagnostic completion race # Conflicts: # extensions/codex/src/app-server/run-attempt.test.ts * ci: fix full release manifest performance run id * refactor: narrow llm plugin sdk boundary * chore: drop generated google boundary stamps * fix: repair rebase fallout * fix: clean up rebased runtime references * fix: decode codex jwt payloads as base64url * fix: preserve shipped pi runtime alias * fix: add scoped sdk virtual modules * fix: decode llm codex oauth jwt as base64url * fix: avoid stale vertex adc negative cache * fix: harden tool arg decoding and codeql path * fix: keep vertex adc negative checks live * refactor: consolidate codex jwt and edit helpers * fix: await codex oauth node runtime imports * fix: preserve sdk tool and notice contracts * fix: preserve shipped compat config boundaries * fix: align codex oauth callback host * fix: terminate agent-core loop streams on failure * fix: keep codex oauth callback alive during fallback * ci: include session tools in critical codeql scans * fix: keep Cloudflare Anthropic provider auth header * docs: redirect legacy pi runtime pages * fix: honor bundled web provider compat discovery * fix: protect session output spill files * fix: keep legacy agent dir env blocked * fix: contain auto-discovered skill symlinks * fix: harden agent core sdk proxy surfaces * fix: restore approval reaction sdk compat * fix: keep live docker runs bounded * fix: keep codex oauth redirect host aligned * fix: resolve post-rebase agent runtime drift * fix: redact anthropic oauth parse failures * fix: preserve responses strict tool shaping * fix: repair agent runtime rebase cleanup * docs: redirect retired parity pages * fix: bound auto-discovered resources to roots * fix: repair post-rebase agent test drift * fix: preserve bundled provider allowlist migration * fix: preserve manifest-owned provider aliases * fix: declare photon image dependency * fix: keep provider headers out of proxy body * fix: preserve shipped env aliases * fix: refresh control ui i18n generated state * fix: quote read fallback paths * fix: preview edits through configured backend * test: satisfy core test typecheck * fix: preserve ZAI usage auth fallback * test: repair codex diagnostic test * fix: repair agent runtime rebase drift * test: finish embedded runner import rename * fix: repair agent runtime rebase integrations * test: align compaction oauth fallback expectations * fix: allow sdk-auth session models * fix: update doctor tool schema import * fix: preserve bedrock plugin region * fix: stream harmony-like prose immediately * ci: include session runtime in codeql shards * fix: repair latest rebase integrations * fix: honor explicit codex websocket transport * fix: keep openai-compatible credentials provider-scoped * fix: refresh sdk api baseline after rebase * fix: route cli runtime aliases through openclaw harness * test: rename stale harness mock expectation * test: rename embedded agent overflow calls * test: clean embedded auth test wording * test: use openclaw stream types in deepinfra cache test * fix: refresh sdk api baseline on latest main * fix: honor bundled discovery compat allowlists * fix: refresh sdk api baseline after latest rebase * fix: remove stale rebase imports * test: rename stale model catalog mock * test: mock renamed doctor runtime modules * fix: map canonical kimi env auth * fix: use internal model registry in bench script * fix: migrate deepinfra provider catalog entry * fix: enforce builtin tool suppression * fix: route compaction auth and proxy payloads safely * refactor: prune unused llm registry leftovers * test: update codex hooks session import * test: fix model picker ci coverage * test: align model picker auth mock types
2600 lines
78 KiB
TypeScript
2600 lines
78 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../config/types.js";
|
|
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
|
|
import { createWarnLogCapture } from "../logging/test-helpers/warn-log-capture.js";
|
|
import { resolveAgentHarnessPolicy } from "./harness/policy.js";
|
|
import { isModelKeyAllowedBySet, providerWildcardModelKey } from "./model-selection-shared.js";
|
|
import {
|
|
buildAllowedModelSet,
|
|
buildConfiguredModelCatalog,
|
|
inferUniqueProviderFromConfiguredModels,
|
|
parseModelRef,
|
|
buildModelAliasIndex,
|
|
normalizeModelSelection,
|
|
normalizeProviderId,
|
|
normalizeProviderIdForAuth,
|
|
modelKey,
|
|
resolvePersistedOverrideModelRef,
|
|
resolvePersistedModelRef,
|
|
resolvePersistedSelectedModelRef,
|
|
resolveAllowedModelRef,
|
|
resolveConfiguredModelRef,
|
|
resolveDefaultModelForAgent,
|
|
resolveSubagentConfiguredModelSelection,
|
|
resolveSubagentSpawnModelSelection,
|
|
resolveThinkingDefault,
|
|
resolveModelRefFromString,
|
|
} from "./model-selection.js";
|
|
import { createModelVisibilityPolicy } from "./model-visibility-policy.js";
|
|
|
|
const manifestNormalizationSnapshot = vi.hoisted(() => ({
|
|
configFingerprint: "model-selection-test-normalizers",
|
|
plugins: [
|
|
{
|
|
id: "model-selection-test-normalizers",
|
|
modelIdNormalization: {
|
|
providers: {
|
|
anthropic: {
|
|
aliases: {
|
|
"opus-4.6": "claude-opus-4-6",
|
|
"opus-4.5": "claude-opus-4-5",
|
|
"sonnet-4.6": "claude-sonnet-4-6",
|
|
"sonnet-4.5": "claude-sonnet-4-5",
|
|
},
|
|
},
|
|
google: {
|
|
aliases: {
|
|
"gemini-3-pro": "gemini-3.1-pro-preview",
|
|
"gemini-3-flash": "gemini-3-flash-preview",
|
|
"gemini-3.1-pro": "gemini-3.1-pro-preview",
|
|
"gemini-3.1-flash-lite-preview": "gemini-3.1-flash-lite",
|
|
"gemini-3.1-flash": "gemini-3-flash-preview",
|
|
"gemini-3.1-flash-preview": "gemini-3-flash-preview",
|
|
},
|
|
},
|
|
"google-vertex": {
|
|
aliases: {
|
|
"gemini-3-pro": "gemini-3.1-pro-preview",
|
|
"gemini-3-flash": "gemini-3-flash-preview",
|
|
"gemini-3.1-pro": "gemini-3.1-pro-preview",
|
|
"gemini-3.1-flash-lite-preview": "gemini-3.1-flash-lite",
|
|
"gemini-3.1-flash": "gemini-3-flash-preview",
|
|
"gemini-3.1-flash-preview": "gemini-3-flash-preview",
|
|
},
|
|
},
|
|
xai: {
|
|
aliases: {
|
|
"grok-4.20-experimental-beta-0304-reasoning": "grok-4.20-beta-latest-reasoning",
|
|
},
|
|
},
|
|
openrouter: {
|
|
prefixWhenBare: "openrouter",
|
|
},
|
|
huggingface: {
|
|
stripPrefixes: ["huggingface/"],
|
|
},
|
|
"vercel-ai-gateway": {
|
|
aliases: {
|
|
"opus-4.6": "claude-opus-4-6",
|
|
"opus-4.5": "claude-opus-4-5",
|
|
"sonnet-4.6": "claude-sonnet-4-6",
|
|
"sonnet-4.5": "claude-sonnet-4-5",
|
|
},
|
|
prefixWhenBareAfterAliasStartsWith: [
|
|
{
|
|
modelPrefix: "claude-",
|
|
prefix: "anthropic",
|
|
},
|
|
],
|
|
},
|
|
nvidia: {
|
|
prefixWhenBare: "nvidia",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}));
|
|
|
|
const providerModelNormalizationMock = vi.hoisted(() => ({
|
|
normalizeProviderModelIdWithRuntime: vi.fn(() => undefined),
|
|
}));
|
|
|
|
vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({
|
|
getCurrentPluginMetadataSnapshot: () => manifestNormalizationSnapshot,
|
|
}));
|
|
|
|
vi.mock("./provider-model-normalization.runtime.js", () => ({
|
|
normalizeProviderModelIdWithRuntime:
|
|
providerModelNormalizationMock.normalizeProviderModelIdWithRuntime,
|
|
}));
|
|
|
|
vi.mock("./model-selection-cli.js", () => ({
|
|
isCliProvider: () => false,
|
|
}));
|
|
|
|
const EXPLICIT_ALLOWLIST_CONFIG = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "openai/gpt-5.4" },
|
|
models: {
|
|
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const BUNDLED_ALLOWLIST_CATALOG = [
|
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.5" },
|
|
{ provider: "openai", id: "gpt-5.4", name: "gpt-5.4" },
|
|
];
|
|
|
|
const ANTHROPIC_OPUS_CATALOG = [
|
|
{
|
|
provider: "anthropic",
|
|
id: "claude-opus-4-6",
|
|
name: "Claude Opus 4.6",
|
|
reasoning: true,
|
|
},
|
|
];
|
|
|
|
const ANTHROPIC_OPUS_47_CATALOG = [
|
|
{
|
|
provider: "anthropic",
|
|
id: "claude-opus-4-7",
|
|
name: "Claude Opus 4.7",
|
|
reasoning: true,
|
|
},
|
|
];
|
|
|
|
function resolveAnthropicOpusThinking(cfg: OpenClawConfig) {
|
|
return resolveThinkingDefault({
|
|
cfg,
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-6",
|
|
catalog: ANTHROPIC_OPUS_CATALOG,
|
|
});
|
|
}
|
|
|
|
function resolveAnthropicOpus47Thinking(cfg: OpenClawConfig) {
|
|
return resolveThinkingDefault({
|
|
cfg,
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-7",
|
|
catalog: ANTHROPIC_OPUS_47_CATALOG,
|
|
});
|
|
}
|
|
|
|
function createAgentFallbackConfig(params: {
|
|
primary?: string;
|
|
fallbacks?: string[];
|
|
agentFallbacks?: string[];
|
|
}) {
|
|
return {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai/gpt-4o": {},
|
|
},
|
|
model: {
|
|
primary: params.primary ?? "openai/gpt-4o",
|
|
fallbacks: params.fallbacks ?? [],
|
|
},
|
|
},
|
|
...(params.agentFallbacks
|
|
? {
|
|
list: [
|
|
{
|
|
id: "coder",
|
|
model: {
|
|
primary: params.primary ?? "openai/gpt-4o",
|
|
fallbacks: params.agentFallbacks,
|
|
},
|
|
},
|
|
],
|
|
}
|
|
: {}),
|
|
},
|
|
} as OpenClawConfig;
|
|
}
|
|
|
|
function createProviderWithModelsConfig(provider: string, models: Array<Record<string, unknown>>) {
|
|
return {
|
|
models: {
|
|
providers: {
|
|
[provider]: {
|
|
baseUrl: `https://${provider}.example.com`,
|
|
models,
|
|
},
|
|
},
|
|
},
|
|
} as Partial<OpenClawConfig>;
|
|
}
|
|
|
|
function resolveConfiguredRefForTest(cfg: Partial<OpenClawConfig>) {
|
|
return resolveConfiguredModelRef({
|
|
cfg: cfg as OpenClawConfig,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.4",
|
|
});
|
|
}
|
|
|
|
describe("model-selection", () => {
|
|
describe("normalizeProviderId", () => {
|
|
it("should normalize provider names", () => {
|
|
expect(normalizeProviderId("Anthropic")).toBe("anthropic");
|
|
expect(normalizeProviderId("Z.ai")).toBe("z.ai");
|
|
expect(normalizeProviderId("z-ai")).toBe("z-ai");
|
|
expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode-zen");
|
|
expect(normalizeProviderId("qwen")).toBe("qwen");
|
|
expect(normalizeProviderId("kimi-code")).toBe("kimi-code");
|
|
expect(normalizeProviderId("kimi-coding")).toBe("kimi-coding");
|
|
expect(normalizeProviderId("MoonshotAI")).toBe("moonshotai");
|
|
expect(normalizeProviderId("moonshot-ai")).toBe("moonshot-ai");
|
|
expect(normalizeProviderId("bedrock")).toBe("bedrock");
|
|
expect(normalizeProviderId("aws-bedrock")).toBe("aws-bedrock");
|
|
expect(normalizeProviderId("amazon-bedrock")).toBe("amazon-bedrock");
|
|
});
|
|
});
|
|
|
|
describe("normalizeProviderIdForAuth", () => {
|
|
it("only applies lowercase provider-id normalization before auth alias lookup", () => {
|
|
expect(normalizeProviderIdForAuth("qwencloud")).toBe("qwencloud");
|
|
expect(normalizeProviderIdForAuth("openai-codex")).toBe("openai-codex");
|
|
expect(normalizeProviderIdForAuth("openai")).toBe("openai");
|
|
});
|
|
});
|
|
|
|
describe("modelKey", () => {
|
|
it("keeps canonical OpenRouter native ids without duplicating the provider", () => {
|
|
expect(modelKey("openrouter", "openrouter/hunter-alpha")).toBe("openrouter/hunter-alpha");
|
|
});
|
|
});
|
|
|
|
describe("parseModelRef", () => {
|
|
const expectParsedModelVariants = (
|
|
variants: string[],
|
|
defaultProvider: string,
|
|
expected: { provider: string; model: string },
|
|
) => {
|
|
for (const raw of variants) {
|
|
expect(
|
|
parseModelRef(raw, defaultProvider, { allowPluginNormalization: false }),
|
|
raw,
|
|
).toEqual(expected);
|
|
}
|
|
};
|
|
|
|
const parseModelRefCases = [
|
|
{
|
|
name: "parses explicit provider/model refs",
|
|
variants: ["anthropic/claude-3-5-sonnet"],
|
|
defaultProvider: "openai",
|
|
expected: { provider: "anthropic", model: "claude-3-5-sonnet" },
|
|
},
|
|
{
|
|
name: "uses the default provider when omitted",
|
|
variants: ["claude-3-5-sonnet"],
|
|
defaultProvider: "anthropic",
|
|
expected: { provider: "anthropic", model: "claude-3-5-sonnet" },
|
|
},
|
|
{
|
|
name: "preserves nested model ids after the provider prefix",
|
|
variants: ["nvidia/moonshotai/kimi-k2.5"],
|
|
defaultProvider: "anthropic",
|
|
expected: { provider: "nvidia", model: "moonshotai/kimi-k2.5" },
|
|
},
|
|
{
|
|
name: "preserves nested MLX model ids after the provider prefix",
|
|
variants: ["mlx/mlx-community/Qwen3-30B-A3B-6bit"],
|
|
defaultProvider: "anthropic",
|
|
expected: { provider: "mlx", model: "mlx-community/Qwen3-30B-A3B-6bit" },
|
|
},
|
|
{
|
|
name: "preserves three-segment refs where the maker equals the provider",
|
|
variants: ["nvidia/nvidia/nemotron-3-super-120b-a12b"],
|
|
defaultProvider: "anthropic",
|
|
expected: { provider: "nvidia", model: "nvidia/nemotron-3-super-120b-a12b" },
|
|
},
|
|
{
|
|
name: "normalizes anthropic shorthand aliases",
|
|
variants: ["anthropic/opus-4.6", "opus-4.6", " anthropic / opus-4.6 "],
|
|
defaultProvider: "anthropic",
|
|
expected: { provider: "anthropic", model: "claude-opus-4-6" },
|
|
},
|
|
{
|
|
name: "normalizes anthropic sonnet aliases",
|
|
variants: ["anthropic/sonnet-4.6", "sonnet-4.6"],
|
|
defaultProvider: "anthropic",
|
|
expected: { provider: "anthropic", model: "claude-sonnet-4-6" },
|
|
},
|
|
{
|
|
name: "keeps dated anthropic model ids unchanged",
|
|
variants: ["anthropic/claude-sonnet-4-20250514", "claude-sonnet-4-20250514"],
|
|
defaultProvider: "anthropic",
|
|
expected: { provider: "anthropic", model: "claude-sonnet-4-20250514" },
|
|
},
|
|
{
|
|
name: "normalizes deprecated google flash preview ids",
|
|
variants: ["google/gemini-3.1-flash-preview", "gemini-3.1-flash-preview"],
|
|
defaultProvider: "google",
|
|
expected: { provider: "google", model: "gemini-3-flash-preview" },
|
|
},
|
|
{
|
|
name: "normalizes retired google gemini 3 pro preview ids",
|
|
variants: ["google/gemini-3-pro-preview", "gemini-3-pro-preview"],
|
|
defaultProvider: "google",
|
|
expected: { provider: "google", model: "gemini-3.1-pro-preview" },
|
|
},
|
|
{
|
|
name: "normalizes retired gemini cli 3 pro preview ids",
|
|
variants: ["google-gemini-cli/gemini-3-pro-preview"],
|
|
defaultProvider: "google",
|
|
expected: { provider: "google-gemini-cli", model: "gemini-3.1-pro-preview" },
|
|
},
|
|
{
|
|
name: "keeps stable GA gemini 3.1 flash-lite ids",
|
|
variants: ["google/gemini-3.1-flash-lite", "gemini-3.1-flash-lite"],
|
|
defaultProvider: "google",
|
|
expected: { provider: "google", model: "gemini-3.1-flash-lite" },
|
|
},
|
|
{
|
|
name: "normalizes deprecated xai grok 4.20 beta ids",
|
|
variants: [
|
|
"xai/grok-4.20-experimental-beta-0304-reasoning",
|
|
"grok-4.20-experimental-beta-0304-reasoning",
|
|
],
|
|
defaultProvider: "xai",
|
|
expected: { provider: "xai", model: "grok-4.20-beta-latest-reasoning" },
|
|
},
|
|
{
|
|
name: "keeps OpenAI codex refs on the openai provider",
|
|
variants: ["openai/gpt-5.4", "gpt-5.4"],
|
|
defaultProvider: "openai",
|
|
expected: { provider: "openai", model: "gpt-5.4" },
|
|
},
|
|
{
|
|
name: "normalizes the openrouter:auto compatibility alias",
|
|
variants: ["openrouter:auto"],
|
|
defaultProvider: "anthropic",
|
|
expected: { provider: "openrouter", model: "openrouter/auto" },
|
|
},
|
|
{
|
|
name: "preserves openrouter native model prefixes",
|
|
variants: ["openrouter/aurora-alpha"],
|
|
defaultProvider: "openai",
|
|
expected: { provider: "openrouter", model: "openrouter/aurora-alpha" },
|
|
},
|
|
{
|
|
name: "passes through openrouter upstream provider ids",
|
|
variants: ["openrouter/anthropic/claude-sonnet-4-6"],
|
|
defaultProvider: "openai",
|
|
expected: { provider: "openrouter", model: "anthropic/claude-sonnet-4-6" },
|
|
},
|
|
{
|
|
name: "strips duplicate Hugging Face provider prefixes",
|
|
variants: ["huggingface/deepseek-ai/DeepSeek-R1"],
|
|
defaultProvider: "huggingface",
|
|
expected: { provider: "huggingface", model: "deepseek-ai/DeepSeek-R1" },
|
|
},
|
|
{
|
|
name: "normalizes Vercel Claude shorthand to anthropic-prefixed model ids",
|
|
variants: ["vercel-ai-gateway/claude-opus-4.6"],
|
|
defaultProvider: "openai",
|
|
expected: { provider: "vercel-ai-gateway", model: "anthropic/claude-opus-4.6" },
|
|
},
|
|
{
|
|
name: "normalizes Vercel Anthropic aliases without double-prefixing",
|
|
variants: ["vercel-ai-gateway/opus-4.6"],
|
|
defaultProvider: "openai",
|
|
expected: { provider: "vercel-ai-gateway", model: "anthropic/claude-opus-4-6" },
|
|
},
|
|
{
|
|
name: "keeps already-prefixed Vercel Anthropic models unchanged",
|
|
variants: ["vercel-ai-gateway/anthropic/claude-opus-4.6"],
|
|
defaultProvider: "openai",
|
|
expected: { provider: "vercel-ai-gateway", model: "anthropic/claude-opus-4.6" },
|
|
},
|
|
{
|
|
name: "passes through non-Claude Vercel model ids unchanged",
|
|
variants: ["vercel-ai-gateway/openai/gpt-5.4"],
|
|
defaultProvider: "openai",
|
|
expected: { provider: "vercel-ai-gateway", model: "openai/gpt-5.4" },
|
|
},
|
|
{
|
|
name: "keeps already-suffixed codex variants unchanged",
|
|
variants: ["openai/gpt-5.4-codex-codex"],
|
|
defaultProvider: "anthropic",
|
|
expected: { provider: "openai", model: "gpt-5.4-codex-codex" },
|
|
},
|
|
{
|
|
name: "keeps stable GA gemini 3.1 flash-lite ids for google-vertex",
|
|
variants: ["google-vertex/gemini-3.1-flash-lite", "gemini-3.1-flash-lite"],
|
|
defaultProvider: "google-vertex",
|
|
expected: { provider: "google-vertex", model: "gemini-3.1-flash-lite" },
|
|
},
|
|
];
|
|
|
|
it("parses and normalizes provider/model refs", () => {
|
|
for (const { variants, defaultProvider, expected } of parseModelRefCases) {
|
|
expectParsedModelVariants(variants, defaultProvider, expected);
|
|
}
|
|
});
|
|
|
|
it("round-trips normalized refs through modelKey", () => {
|
|
const parsed = parseModelRef(" opus-4.6 ", "anthropic", {
|
|
allowPluginNormalization: false,
|
|
});
|
|
expect(parsed).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
|
|
expect(modelKey(parsed?.provider ?? "", parsed?.model ?? "")).toBe(
|
|
"anthropic/claude-opus-4-6",
|
|
);
|
|
});
|
|
it("returns null for invalid refs", () => {
|
|
for (const raw of ["", " ", "/", "anthropic/", "/model"]) {
|
|
expect(
|
|
parseModelRef(raw, "anthropic", { allowPluginNormalization: false }),
|
|
raw,
|
|
).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("resolvePersistedModelRef", () => {
|
|
it("splits legacy combined refs when provider is not stored separately", () => {
|
|
expect(
|
|
resolvePersistedModelRef({
|
|
defaultProvider: "anthropic",
|
|
overrideModel: "ollama-beelink2/qwen2.5-coder:7b",
|
|
}),
|
|
).toEqual({
|
|
provider: "ollama-beelink2",
|
|
model: "qwen2.5-coder:7b",
|
|
});
|
|
});
|
|
|
|
it("preserves explicit runtime provider for vendor-prefixed model ids", () => {
|
|
expect(
|
|
resolvePersistedModelRef({
|
|
defaultProvider: "anthropic",
|
|
runtimeProvider: "openrouter",
|
|
runtimeModel: "anthropic/claude-haiku-4.5",
|
|
}),
|
|
).toEqual({
|
|
provider: "openrouter",
|
|
model: "anthropic/claude-haiku-4.5",
|
|
});
|
|
});
|
|
|
|
it("preserves explicit override provider ids without reparsing runtime semantics", () => {
|
|
expect(
|
|
resolvePersistedModelRef({
|
|
defaultProvider: "anthropic",
|
|
overrideProvider: "kimi-coding",
|
|
overrideModel: "kimi-code",
|
|
}),
|
|
).toEqual({
|
|
provider: "kimi-coding",
|
|
model: "kimi-code",
|
|
});
|
|
});
|
|
|
|
it("ignores malformed persisted model fields and tolerates a missing default provider", () => {
|
|
expect(
|
|
resolvePersistedModelRef({
|
|
defaultProvider: undefined,
|
|
runtimeProvider: { provider: "openai" },
|
|
runtimeModel: false,
|
|
overrideProvider: ["anthropic"],
|
|
overrideModel: 123,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("resolvePersistedOverrideModelRef", () => {
|
|
it("splits legacy combined override refs when provider is not stored separately", () => {
|
|
expect(
|
|
resolvePersistedOverrideModelRef({
|
|
defaultProvider: "anthropic",
|
|
overrideModel: "ollama-beelink2/qwen2.5-coder:7b",
|
|
}),
|
|
).toEqual({
|
|
provider: "ollama-beelink2",
|
|
model: "qwen2.5-coder:7b",
|
|
});
|
|
});
|
|
|
|
it("preserves explicit override provider ids without reparsing away wrapper semantics", () => {
|
|
expect(
|
|
resolvePersistedOverrideModelRef({
|
|
defaultProvider: "anthropic",
|
|
overrideProvider: "kimi-coding",
|
|
overrideModel: "kimi-code",
|
|
}),
|
|
).toEqual({
|
|
provider: "kimi-coding",
|
|
model: "kimi-code",
|
|
});
|
|
});
|
|
|
|
it("ignores malformed persisted override fields", () => {
|
|
expect(
|
|
resolvePersistedOverrideModelRef({
|
|
defaultProvider: undefined,
|
|
overrideProvider: ["anthropic"],
|
|
overrideModel: 123,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("resolvePersistedSelectedModelRef", () => {
|
|
it("prefers explicit overrides ahead of runtime model fields", () => {
|
|
expect(
|
|
resolvePersistedSelectedModelRef({
|
|
defaultProvider: "anthropic",
|
|
runtimeProvider: "openai-codex",
|
|
runtimeModel: "gpt-5.4",
|
|
overrideProvider: "anthropic",
|
|
overrideModel: "claude-opus-4-6",
|
|
}),
|
|
).toEqual({
|
|
provider: "anthropic",
|
|
model: "claude-opus-4-6",
|
|
});
|
|
});
|
|
|
|
it("preserves explicit wrapper providers for vendor-prefixed override models", () => {
|
|
expect(
|
|
resolvePersistedSelectedModelRef({
|
|
defaultProvider: "anthropic",
|
|
runtimeProvider: "openrouter",
|
|
runtimeModel: "openrouter/free",
|
|
overrideProvider: "openrouter",
|
|
overrideModel: "anthropic/claude-haiku-4.5",
|
|
}),
|
|
).toEqual({
|
|
provider: "openrouter",
|
|
model: "anthropic/claude-haiku-4.5",
|
|
});
|
|
});
|
|
|
|
it("ignores malformed persisted model metadata instead of throwing", () => {
|
|
expect(
|
|
resolvePersistedSelectedModelRef({
|
|
defaultProvider: "anthropic",
|
|
runtimeProvider: { provider: "openai" },
|
|
runtimeModel: false,
|
|
overrideProvider: ["openrouter"],
|
|
overrideModel: 123,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("inferUniqueProviderFromConfiguredModels", () => {
|
|
it("infers provider when configured model match is unique", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"anthropic/claude-sonnet-4-6": {},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
expect(
|
|
inferUniqueProviderFromConfiguredModels({
|
|
cfg,
|
|
model: "claude-sonnet-4-6",
|
|
}),
|
|
).toBe("anthropic");
|
|
});
|
|
|
|
it("returns undefined when configured matches are ambiguous", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"anthropic/claude-sonnet-4-6": {},
|
|
"minimax/claude-sonnet-4-6": {},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
expect(
|
|
inferUniqueProviderFromConfiguredModels({
|
|
cfg,
|
|
model: "claude-sonnet-4-6",
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("returns undefined for provider-prefixed model ids", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"anthropic/claude-sonnet-4-6": {},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
expect(
|
|
inferUniqueProviderFromConfiguredModels({
|
|
cfg,
|
|
model: "anthropic/claude-sonnet-4-6",
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("infers provider for slash-containing model id when allowlist match is unique", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"vercel-ai-gateway/anthropic/claude-sonnet-4-6": {},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
expect(
|
|
inferUniqueProviderFromConfiguredModels({
|
|
cfg,
|
|
model: "anthropic/claude-sonnet-4-6",
|
|
}),
|
|
).toBe("vercel-ai-gateway");
|
|
});
|
|
|
|
it("infers provider from configured provider catalogs when allowlist is absent", () => {
|
|
const cfg = {
|
|
models: {
|
|
providers: {
|
|
"qwen-dashscope": {
|
|
models: [{ id: "qwen-max" }],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
expect(
|
|
inferUniqueProviderFromConfiguredModels({
|
|
cfg,
|
|
model: "qwen-max",
|
|
}),
|
|
).toBe("qwen-dashscope");
|
|
});
|
|
|
|
it("infers provider from raw configured ids when manifest policies add prefixes", () => {
|
|
const cfg = {
|
|
models: {
|
|
providers: {
|
|
nvidia: {
|
|
models: [{ id: "llama-fast" }],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
expect(
|
|
inferUniqueProviderFromConfiguredModels({
|
|
cfg,
|
|
model: "llama-fast",
|
|
}),
|
|
).toBe("nvidia");
|
|
});
|
|
|
|
it("infers Google provider from canonicalized configured provider catalogs", () => {
|
|
const cfg = {
|
|
models: {
|
|
providers: {
|
|
google: {
|
|
models: [{ id: "gemini-3-pro-preview" }],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
expect(
|
|
inferUniqueProviderFromConfiguredModels({
|
|
cfg,
|
|
model: "gemini-3.1-pro-preview",
|
|
}),
|
|
).toBe("google");
|
|
});
|
|
|
|
it("infers proxy providers from canonicalized nested Google catalog ids", () => {
|
|
const cfg = {
|
|
models: {
|
|
providers: {
|
|
kilocode: {
|
|
models: [{ id: "google/gemini-3-pro-preview" }],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
expect(
|
|
inferUniqueProviderFromConfiguredModels({
|
|
cfg,
|
|
model: "google/gemini-3.1-pro-preview",
|
|
}),
|
|
).toBe("kilocode");
|
|
});
|
|
|
|
it("returns undefined when provider catalog matches are ambiguous", () => {
|
|
const cfg = {
|
|
models: {
|
|
providers: {
|
|
"qwen-dashscope": {
|
|
models: [{ id: "qwen-max" }],
|
|
},
|
|
qwen: {
|
|
models: [{ id: "qwen-max" }],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
expect(
|
|
inferUniqueProviderFromConfiguredModels({
|
|
cfg,
|
|
model: "qwen-max",
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("buildConfiguredModelCatalog", () => {
|
|
it("emits canonical Google Gemini 3.1 provider model ids", () => {
|
|
const cfg = {
|
|
models: {
|
|
providers: {
|
|
google: {
|
|
models: [
|
|
{
|
|
id: "gemini-3-pro-preview",
|
|
name: "Gemini 3 Pro",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const model = buildConfiguredModelCatalog({ cfg }).find(
|
|
(entry) => entry.provider === "google" && entry.id === "gemini-3.1-pro-preview",
|
|
);
|
|
expect(model?.provider).toBe("google");
|
|
expect(model?.id).toBe("gemini-3.1-pro-preview");
|
|
expect(model?.name).toBe("Gemini 3 Pro");
|
|
});
|
|
|
|
it("emits canonical nested Google Gemini 3.1 ids from proxy provider catalog rows", () => {
|
|
const cfg = {
|
|
models: {
|
|
providers: {
|
|
kilocode: {
|
|
models: [
|
|
{
|
|
id: "google/gemini-3-pro-preview",
|
|
name: "Gemini 3 Pro",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const model = buildConfiguredModelCatalog({ cfg }).find(
|
|
(entry) => entry.provider === "kilocode" && entry.id === "google/gemini-3.1-pro-preview",
|
|
);
|
|
expect(model?.provider).toBe("kilocode");
|
|
expect(model?.id).toBe("google/gemini-3.1-pro-preview");
|
|
expect(model?.name).toBe("Gemini 3 Pro");
|
|
});
|
|
|
|
it("carries configured model compat into catalog entries for provider policy", () => {
|
|
const cfg = {
|
|
models: {
|
|
providers: {
|
|
vllm: {
|
|
models: [
|
|
{
|
|
id: "Qwen/Qwen3-8B",
|
|
name: "Qwen 3 8B",
|
|
reasoning: true,
|
|
compat: {
|
|
thinkingFormat: "qwen-chat-template",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const model = buildConfiguredModelCatalog({ cfg }).find(
|
|
(entry) => entry.provider === "vllm" && entry.id === "Qwen/Qwen3-8B",
|
|
);
|
|
expect(model?.compat).toEqual({ thinkingFormat: "qwen-chat-template" });
|
|
expect(model?.reasoning).toBe(true);
|
|
});
|
|
|
|
it("does not infer reasoning from non-vLLM thinking compat", () => {
|
|
const cfg = {
|
|
models: {
|
|
providers: {
|
|
custom: {
|
|
models: [
|
|
{
|
|
id: "custom-reasoning",
|
|
name: "Custom Reasoning",
|
|
compat: {
|
|
thinkingFormat: "together",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const model = buildConfiguredModelCatalog({ cfg }).find(
|
|
(entry) => entry.provider === "custom" && entry.id === "custom-reasoning",
|
|
);
|
|
expect(model?.compat).toEqual({ thinkingFormat: "together" });
|
|
expect(model?.reasoning).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("buildModelAliasIndex", () => {
|
|
it("should build alias index from config", () => {
|
|
const cfg: Partial<OpenClawConfig> = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"anthropic/claude-3-5-sonnet": { alias: "fast" },
|
|
"openai/gpt-4o": { alias: "smart" },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const index = buildModelAliasIndex({
|
|
cfg: cfg as OpenClawConfig,
|
|
defaultProvider: "anthropic",
|
|
});
|
|
|
|
expect(index.byAlias.get("fast")?.ref).toEqual({
|
|
provider: "anthropic",
|
|
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"]);
|
|
});
|
|
|
|
it("does not normalize configured model keys that have no alias", () => {
|
|
providerModelNormalizationMock.normalizeProviderModelIdWithRuntime.mockClear();
|
|
const models = Object.fromEntries(
|
|
Array.from({ length: 25 }, (_, index) => [`openai/gpt-5.5-aliasless-${index}`, {}]),
|
|
);
|
|
const cfg: Partial<OpenClawConfig> = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
...models,
|
|
"openai/gpt-5.5-mini": { alias: "mini" },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const index = buildModelAliasIndex({
|
|
cfg: cfg as OpenClawConfig,
|
|
defaultProvider: "openai",
|
|
});
|
|
|
|
expect(index.byAlias.get("mini")?.ref).toEqual({
|
|
provider: "openai",
|
|
model: "gpt-5.5-mini",
|
|
});
|
|
expect(
|
|
providerModelNormalizationMock.normalizeProviderModelIdWithRuntime,
|
|
).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("buildAllowedModelSet", () => {
|
|
it("keeps explicitly allowlisted models even when missing from bundled catalog", () => {
|
|
const result = buildAllowedModelSet({
|
|
cfg: EXPLICIT_ALLOWLIST_CONFIG,
|
|
catalog: BUNDLED_ALLOWLIST_CATALOG,
|
|
defaultProvider: "anthropic",
|
|
});
|
|
|
|
expect(result.allowAny).toBe(false);
|
|
expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(true);
|
|
expect(result.allowedCatalog).toEqual([
|
|
{
|
|
provider: "anthropic",
|
|
id: "claude-sonnet-4-6",
|
|
name: "Claude Sonnet 4.5",
|
|
alias: "sonnet",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("overlays configured provider metadata and alias onto matching catalog entries", () => {
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "openai/gpt-test-z" },
|
|
models: {
|
|
"openai/gpt-test-z": { alias: "GPT Test Z Alias" },
|
|
},
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
openai: {
|
|
baseUrl: "https://openai.example.com",
|
|
models: [
|
|
{
|
|
id: "gpt-test-z",
|
|
name: "Configured GPT Test Z",
|
|
contextWindow: 64_000,
|
|
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const result = buildAllowedModelSet({
|
|
cfg,
|
|
catalog: [{ provider: "openai", id: "gpt-test-z", name: "gpt-test-z" }],
|
|
defaultProvider: "anthropic",
|
|
});
|
|
|
|
expect(result.allowAny).toBe(false);
|
|
expect(result.allowedCatalog).toEqual([
|
|
{
|
|
provider: "openai",
|
|
id: "gpt-test-z",
|
|
name: "Configured GPT Test Z",
|
|
alias: "GPT Test Z Alias",
|
|
contextWindow: 64_000,
|
|
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("overlays configured provider metadata after manifest model normalization", () => {
|
|
const cfg: OpenClawConfig = {
|
|
models: {
|
|
providers: {
|
|
nvidia: {
|
|
models: [
|
|
{
|
|
id: "llama-fast",
|
|
name: "Configured Llama Fast",
|
|
contextWindow: 128_000,
|
|
reasoning: true,
|
|
compat: { thinkingFormat: "qwen" },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const result = buildAllowedModelSet({
|
|
cfg,
|
|
catalog: [{ provider: "nvidia", id: "nvidia/llama-fast", name: "Runtime Llama Fast" }],
|
|
defaultProvider: "anthropic",
|
|
});
|
|
|
|
expect(result.allowedCatalog).toEqual([
|
|
{
|
|
provider: "nvidia",
|
|
id: "nvidia/llama-fast",
|
|
name: "Configured Llama Fast",
|
|
contextWindow: 128_000,
|
|
reasoning: true,
|
|
compat: { thinkingFormat: "qwen" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("keeps configured provider models visible when the catalog is otherwise allow-any", () => {
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "ollama/existing" },
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
ollama: {
|
|
baseUrl: "http://127.0.0.1:11434",
|
|
api: "ollama",
|
|
apiKey: "ollama-local",
|
|
models: [
|
|
{
|
|
id: "glm-5.1:cloud",
|
|
name: "GLM 5.1 Cloud",
|
|
contextWindow: 131_072,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const result = buildAllowedModelSet({
|
|
cfg,
|
|
catalog: [{ provider: "ollama", id: "existing", name: "Existing" }],
|
|
defaultProvider: "ollama",
|
|
defaultModel: "existing",
|
|
});
|
|
|
|
expect(result.allowAny).toBe(true);
|
|
expect(result.allowedCatalog).toEqual([
|
|
{ provider: "ollama", id: "existing", name: "Existing" },
|
|
{
|
|
provider: "ollama",
|
|
id: "glm-5.1:cloud",
|
|
name: "GLM 5.1 Cloud",
|
|
contextWindow: 131_072,
|
|
},
|
|
]);
|
|
expect(result.allowedKeys.has("ollama/glm-5.1:cloud")).toBe(true);
|
|
});
|
|
|
|
it("allows every discovered catalog model for provider wildcard entries", () => {
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai-codex/*": {},
|
|
"vllm/*": {},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const result = buildAllowedModelSet({
|
|
cfg,
|
|
catalog: [
|
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
|
|
{ provider: "openai-codex", id: "gpt-5.4-codex", name: "GPT-5.4 Codex" },
|
|
{ provider: "openai-codex", id: "gpt-5.5-codex", name: "GPT-5.5 Codex" },
|
|
{ provider: "vllm", id: "qwen3-local", name: "Qwen3 Local" },
|
|
{ provider: "vllm", id: "local-added-after-startup", name: "Local Added After Startup" },
|
|
],
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
});
|
|
|
|
expect(result.allowAny).toBe(false);
|
|
expect(result.allowedCatalog).toEqual([
|
|
{ provider: "openai-codex", id: "gpt-5.4-codex", name: "GPT-5.4 Codex" },
|
|
{ provider: "openai-codex", id: "gpt-5.5-codex", name: "GPT-5.5 Codex" },
|
|
{ provider: "vllm", id: "qwen3-local", name: "Qwen3 Local" },
|
|
{ provider: "vllm", id: "local-added-after-startup", name: "Local Added After Startup" },
|
|
]);
|
|
expect(result.allowedKeys.has("openai-codex/gpt-5.4-codex")).toBe(true);
|
|
expect(result.allowedKeys.has("openai-codex/gpt-5.5-codex")).toBe(true);
|
|
expect(result.allowedKeys.has("vllm/local-added-after-startup")).toBe(true);
|
|
expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(false);
|
|
});
|
|
|
|
it("preserves provider wildcard intent when catalog rows are unavailable", () => {
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai-codex/*": {},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const result = buildAllowedModelSet({
|
|
cfg,
|
|
catalog: [],
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
});
|
|
|
|
expect(result.allowAny).toBe(false);
|
|
expect(result.allowedCatalog).toEqual([]);
|
|
expect(result.allowedKeys.has(providerWildcardModelKey("openai-codex"))).toBe(true);
|
|
expect(isModelKeyAllowedBySet(result.allowedKeys, "openai-codex/gpt-added-later")).toBe(true);
|
|
expect(isModelKeyAllowedBySet(result.allowedKeys, "anthropic/claude-sonnet-4-6")).toBe(false);
|
|
});
|
|
|
|
it("exposes wildcard allow and visible catalog behavior through one policy", () => {
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai-codex/*": {},
|
|
"anthropic/claude-sonnet-4-6": {},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const policy = createModelVisibilityPolicy({
|
|
cfg,
|
|
catalog: [
|
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
|
|
{ provider: "openai-codex", id: "gpt-added-later", name: "GPT Added Later" },
|
|
{ provider: "vllm", id: "qwen-local", name: "Qwen Local" },
|
|
],
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
});
|
|
|
|
expect(policy.hasProviderWildcards).toBe(true);
|
|
expect(policy.allows({ provider: "openai-codex", model: "future-model" })).toBe(true);
|
|
expect(policy.allows({ provider: "vllm", model: "qwen-local" })).toBe(false);
|
|
expect(
|
|
policy.visibleCatalog({
|
|
catalog: [],
|
|
defaultVisibleCatalog: [
|
|
{ provider: "openai-codex", id: "gpt-added-later", name: "GPT Added Later" },
|
|
{ provider: "vllm", id: "qwen-local", name: "Qwen Local" },
|
|
],
|
|
}),
|
|
).toEqual([
|
|
{ provider: "openai-codex", id: "gpt-added-later", name: "GPT Added Later" },
|
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
|
|
]);
|
|
});
|
|
|
|
it("keeps exact same-provider entries visible beside wildcard catalog rows", () => {
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"vllm/*": {},
|
|
"vllm/manual": {},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const policy = createModelVisibilityPolicy({
|
|
cfg,
|
|
catalog: [{ provider: "vllm", id: "qwen-local", name: "Qwen Local" }],
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
});
|
|
|
|
expect(
|
|
policy.visibleCatalog({
|
|
catalog: [],
|
|
defaultVisibleCatalog: [{ provider: "vllm", id: "qwen-local", name: "Qwen Local" }],
|
|
}),
|
|
).toEqual([
|
|
{ provider: "vllm", id: "qwen-local", name: "Qwen Local" },
|
|
{ provider: "vllm", id: "manual", name: "manual" },
|
|
]);
|
|
});
|
|
|
|
it("does not re-add a default outside mixed wildcard and exact filters", () => {
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai-codex/*": {},
|
|
"google/gemini-test": {},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const result = buildAllowedModelSet({
|
|
cfg,
|
|
catalog: [
|
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
|
|
{ provider: "openai-codex", id: "gpt-codex", name: "GPT Codex" },
|
|
{ provider: "google", id: "gemini-test", name: "Gemini Test" },
|
|
],
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
});
|
|
|
|
expect(result.allowedCatalog).toEqual([
|
|
{ provider: "openai-codex", id: "gpt-codex", name: "GPT Codex" },
|
|
{ provider: "google", id: "gemini-test", name: "Gemini Test" },
|
|
]);
|
|
expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(false);
|
|
expect(result.allowedKeys.has(providerWildcardModelKey("openai-codex"))).toBe(true);
|
|
});
|
|
|
|
it("unions exact model entries with provider wildcard entries", () => {
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"anthropic/claude-sonnet-4-6": {},
|
|
"openai-codex/*": {},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const result = buildAllowedModelSet({
|
|
cfg,
|
|
catalog: [
|
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
|
|
{ provider: "openai-codex", id: "gpt-5.4-codex", name: "GPT-5.4 Codex" },
|
|
{ provider: "openai-codex", id: "gpt-5.5-codex", name: "GPT-5.5 Codex" },
|
|
{ provider: "vllm", id: "qwen-local", name: "Qwen Local" },
|
|
],
|
|
defaultProvider: "anthropic",
|
|
});
|
|
|
|
expect(result.allowAny).toBe(false);
|
|
expect(result.allowedCatalog).toEqual([
|
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" },
|
|
{ provider: "openai-codex", id: "gpt-5.4-codex", name: "GPT-5.4 Codex" },
|
|
{ provider: "openai-codex", id: "gpt-5.5-codex", name: "GPT-5.5 Codex" },
|
|
]);
|
|
expect(result.allowedKeys.has("openai-codex/gpt-5.5-codex")).toBe(true);
|
|
expect(result.allowedKeys.has("vllm/qwen-local")).toBe(false);
|
|
});
|
|
|
|
it("matches allowlisted catalog entries with normalized provider and model ids", () => {
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"modelscope/Qwen/Qwen3.5-35B-A3B": {},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const result = buildAllowedModelSet({
|
|
cfg,
|
|
catalog: [
|
|
{
|
|
provider: "modelscope",
|
|
id: "qwen/qwen3.5-35b-a3b",
|
|
name: "Qwen3.5 35B",
|
|
input: ["text", "image"],
|
|
},
|
|
],
|
|
defaultProvider: "anthropic",
|
|
});
|
|
|
|
expect(result.allowedCatalog).toHaveLength(1);
|
|
const allowed = result.allowedCatalog[0];
|
|
expect(allowed?.provider).toBe("modelscope");
|
|
expect(allowed?.id).toBe("qwen/qwen3.5-35b-a3b");
|
|
expect(allowed?.input).toEqual(["text", "image"]);
|
|
});
|
|
|
|
it("applies configured provider metadata and alias to synthetic allowlist entries", () => {
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "nvidia/moonshotai/kimi-k2.5" },
|
|
models: {
|
|
"nvidia/moonshotai/kimi-k2.5": { alias: "Kimi K2.5 (NVIDIA)" },
|
|
},
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
nvidia: {
|
|
baseUrl: "https://nvidia.example.com",
|
|
models: [
|
|
{
|
|
id: "moonshotai/kimi-k2.5",
|
|
name: "Kimi K2.5 (Configured)",
|
|
contextWindow: 32_000,
|
|
reasoning: true,
|
|
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const result = buildAllowedModelSet({
|
|
cfg,
|
|
catalog: [],
|
|
defaultProvider: "anthropic",
|
|
});
|
|
|
|
expect(result.allowAny).toBe(false);
|
|
expect(result.allowedCatalog).toEqual([
|
|
{
|
|
provider: "nvidia",
|
|
id: "moonshotai/kimi-k2.5",
|
|
name: "Kimi K2.5 (Configured)",
|
|
alias: "Kimi K2.5 (NVIDIA)",
|
|
contextWindow: 32_000,
|
|
reasoning: true,
|
|
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("includes fallback models in allowed set", () => {
|
|
const cfg = createAgentFallbackConfig({
|
|
fallbacks: ["anthropic/claude-sonnet-4-6", "google/gemini-3-pro"],
|
|
});
|
|
|
|
const result = buildAllowedModelSet({
|
|
cfg,
|
|
catalog: [],
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-4o",
|
|
});
|
|
|
|
expect(result.allowedKeys.has("openai/gpt-4o")).toBe(true);
|
|
expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(true);
|
|
expect(result.allowedKeys.has("google/gemini-3.1-pro-preview")).toBe(true);
|
|
expect(result.allowAny).toBe(false);
|
|
});
|
|
|
|
it("handles empty fallbacks gracefully", () => {
|
|
const cfg = createAgentFallbackConfig({});
|
|
|
|
const result = buildAllowedModelSet({
|
|
cfg,
|
|
catalog: [],
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-4o",
|
|
});
|
|
|
|
expect(result.allowedKeys.has("openai/gpt-4o")).toBe(true);
|
|
expect(result.allowAny).toBe(false);
|
|
});
|
|
|
|
it("prefers per-agent fallback overrides when agentId is provided", () => {
|
|
const cfg = createAgentFallbackConfig({
|
|
fallbacks: ["google/gemini-3-pro"],
|
|
agentFallbacks: ["anthropic/claude-sonnet-4-6"],
|
|
});
|
|
|
|
const result = buildAllowedModelSet({
|
|
cfg,
|
|
catalog: [],
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-4o",
|
|
agentId: "coder",
|
|
});
|
|
|
|
expect(result.allowedKeys.has("openai/gpt-4o")).toBe(true);
|
|
expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(true);
|
|
expect(result.allowedKeys.has("google/gemini-3.1-pro-preview")).toBe(false);
|
|
expect(result.allowAny).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("resolveAllowedModelRef", () => {
|
|
it("accepts explicit allowlist refs absent from bundled catalog", () => {
|
|
const result = resolveAllowedModelRef({
|
|
cfg: EXPLICIT_ALLOWLIST_CONFIG,
|
|
catalog: BUNDLED_ALLOWLIST_CATALOG,
|
|
raw: "anthropic/claude-sonnet-4-6",
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.4",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
key: "anthropic/claude-sonnet-4-6",
|
|
ref: { provider: "anthropic", model: "claude-sonnet-4-6" },
|
|
});
|
|
});
|
|
|
|
it("keeps legacy CLI runtime refs accepted when canonical runtime refs are also configured", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
agentRuntime: { id: "claude-cli" },
|
|
model: { primary: "anthropic/claude-sonnet-4-6" },
|
|
models: {
|
|
"anthropic/claude-sonnet-4-6": {},
|
|
"claude-cli/claude-sonnet-4-6": {},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = resolveAllowedModelRef({
|
|
cfg,
|
|
catalog: BUNDLED_ALLOWLIST_CATALOG,
|
|
raw: "claude-cli/claude-sonnet-4-6",
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
key: "claude-cli/claude-sonnet-4-6",
|
|
ref: { provider: "claude-cli", model: "claude-sonnet-4-6" },
|
|
});
|
|
});
|
|
|
|
it("strips trailing auth profile suffix before allowlist matching", () => {
|
|
const cfg: OpenClawConfig = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai/@cf/openai/gpt-oss-20b": {},
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const result = resolveAllowedModelRef({
|
|
cfg,
|
|
catalog: [],
|
|
raw: "openai/@cf/openai/gpt-oss-20b@cf:default",
|
|
defaultProvider: "anthropic",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
key: "openai/@cf/openai/gpt-oss-20b",
|
|
ref: { provider: "openai", model: "@cf/openai/gpt-oss-20b" },
|
|
});
|
|
});
|
|
|
|
it("infers provider from allowlist for bare model ids to prevent prefix drift (#48369)", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai-codex/gpt-5.4": {},
|
|
"opencode-go/kimi-k2.6": {},
|
|
"opencode-go/glm-5": {},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
// When session default is openai-codex, switching to a bare "kimi-k2.6"
|
|
// should resolve to opencode-go/kimi-k2.6, not openai-codex/kimi-k2.6
|
|
const result = resolveAllowedModelRef({
|
|
cfg,
|
|
catalog: [],
|
|
raw: "kimi-k2.6",
|
|
defaultProvider: "openai-codex", // session's current provider
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
key: "opencode-go/kimi-k2.6",
|
|
ref: { provider: "opencode-go", model: "kimi-k2.6" },
|
|
});
|
|
});
|
|
|
|
it("resolves slash-form aliases before provider/model parsing", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai/xiaomi/mimo-v2-pro-mit": {
|
|
alias: "xiaomi/mimo-v2-pro-mit",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = resolveAllowedModelRef({
|
|
cfg,
|
|
catalog: [],
|
|
raw: "xiaomi/mimo-v2-pro-mit",
|
|
defaultProvider: "openai",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
key: "openai/xiaomi/mimo-v2-pro-mit",
|
|
ref: { provider: "openai", model: "xiaomi/mimo-v2-pro-mit" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveModelRefFromString", () => {
|
|
it("should resolve from string with alias", () => {
|
|
const index = {
|
|
byAlias: new Map([
|
|
["fast", { alias: "fast", ref: { provider: "anthropic", model: "sonnet" } }],
|
|
]),
|
|
byKey: new Map(),
|
|
};
|
|
|
|
const resolved = resolveModelRefFromString({
|
|
raw: "fast",
|
|
defaultProvider: "openai",
|
|
aliasIndex: index,
|
|
});
|
|
|
|
expect(resolved?.ref).toEqual({ provider: "anthropic", model: "sonnet" });
|
|
expect(resolved?.alias).toBe("fast");
|
|
});
|
|
|
|
it("should resolve direct ref if no alias match", () => {
|
|
const resolved = resolveModelRefFromString({
|
|
raw: "openai/gpt-4",
|
|
defaultProvider: "anthropic",
|
|
});
|
|
expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-4" });
|
|
});
|
|
|
|
it("prefers slash-form aliases over direct provider/model parsing", () => {
|
|
const index = {
|
|
byAlias: new Map([
|
|
[
|
|
"xiaomi/mimo-v2-pro-mit",
|
|
{
|
|
alias: "xiaomi/mimo-v2-pro-mit",
|
|
ref: { provider: "openai", model: "xiaomi/mimo-v2-pro-mit" },
|
|
},
|
|
],
|
|
]),
|
|
byKey: new Map(),
|
|
};
|
|
|
|
const resolved = resolveModelRefFromString({
|
|
raw: "xiaomi/mimo-v2-pro-mit",
|
|
defaultProvider: "anthropic",
|
|
aliasIndex: index,
|
|
});
|
|
|
|
expect(resolved?.ref).toEqual({ provider: "openai", model: "xiaomi/mimo-v2-pro-mit" });
|
|
expect(resolved?.alias).toBe("xiaomi/mimo-v2-pro-mit");
|
|
});
|
|
|
|
it("strips trailing profile suffix for simple model refs", () => {
|
|
const resolved = resolveModelRefFromString({
|
|
raw: "gpt-5@myprofile",
|
|
defaultProvider: "openai",
|
|
});
|
|
expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-5" });
|
|
});
|
|
|
|
it("strips trailing profile suffix for provider/model refs", () => {
|
|
const resolved = resolveModelRefFromString({
|
|
raw: "google/gemini-flash-latest@google:bevfresh",
|
|
defaultProvider: "anthropic",
|
|
});
|
|
expect(resolved?.ref).toEqual({
|
|
provider: "google",
|
|
model: "gemini-flash-latest",
|
|
});
|
|
});
|
|
|
|
it("preserves Cloudflare @cf model segments", () => {
|
|
const resolved = resolveModelRefFromString({
|
|
raw: "openai/@cf/openai/gpt-oss-20b",
|
|
defaultProvider: "anthropic",
|
|
});
|
|
expect(resolved?.ref).toEqual({
|
|
provider: "openai",
|
|
model: "@cf/openai/gpt-oss-20b",
|
|
});
|
|
});
|
|
|
|
it("preserves OpenRouter @preset model segments", () => {
|
|
const resolved = resolveModelRefFromString({
|
|
raw: "openrouter/@preset/kimi-2-5",
|
|
defaultProvider: "anthropic",
|
|
});
|
|
expect(resolved?.ref).toEqual({
|
|
provider: "openrouter",
|
|
model: "@preset/kimi-2-5",
|
|
});
|
|
});
|
|
|
|
it("splits trailing profile suffix after OpenRouter preset paths", () => {
|
|
const resolved = resolveModelRefFromString({
|
|
raw: "openrouter/@preset/kimi-2-5@work",
|
|
defaultProvider: "anthropic",
|
|
});
|
|
expect(resolved?.ref).toEqual({
|
|
provider: "openrouter",
|
|
model: "@preset/kimi-2-5",
|
|
});
|
|
});
|
|
|
|
it("preserves LM Studio @iq* quant suffixes", () => {
|
|
const resolved = resolveModelRefFromString({
|
|
raw: "lmstudio/qwen3.6-27b@iq3_xxs",
|
|
defaultProvider: "anthropic",
|
|
});
|
|
|
|
expect(resolved?.ref).toEqual({
|
|
provider: "lmstudio",
|
|
model: "qwen3.6-27b@iq3_xxs",
|
|
});
|
|
});
|
|
|
|
it("splits trailing profile suffix after LM Studio @iq* quant suffixes", () => {
|
|
const resolved = resolveModelRefFromString({
|
|
raw: "lmstudio/qwen3.6-27b@iq3_xxs@work",
|
|
defaultProvider: "anthropic",
|
|
});
|
|
|
|
expect(resolved?.ref).toEqual({
|
|
provider: "lmstudio",
|
|
model: "qwen3.6-27b@iq3_xxs",
|
|
});
|
|
});
|
|
|
|
it("strips profile suffix before alias resolution", () => {
|
|
const index = {
|
|
byAlias: new Map([
|
|
["kimi", { alias: "kimi", ref: { provider: "nvidia", model: "moonshotai/kimi-k2.5" } }],
|
|
]),
|
|
byKey: new Map(),
|
|
};
|
|
|
|
const resolved = resolveModelRefFromString({
|
|
raw: "kimi@nvidia:default",
|
|
defaultProvider: "openai",
|
|
aliasIndex: index,
|
|
});
|
|
expect(resolved?.ref).toEqual({
|
|
provider: "nvidia",
|
|
model: "moonshotai/kimi-k2.5",
|
|
});
|
|
expect(resolved?.alias).toBe("kimi");
|
|
});
|
|
});
|
|
|
|
describe("resolveConfiguredModelRef", () => {
|
|
it("should infer the unique provider from configured models for bare defaults", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "claude-opus-4-6" },
|
|
models: {
|
|
"anthropic/claude-opus-4-6": {},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.4",
|
|
});
|
|
|
|
expect(result).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
|
|
});
|
|
|
|
it("should fall back to the configured default provider and warn if provider is missing for non-alias", async () => {
|
|
const warnLogs = createWarnLogCapture("openclaw-model-selection-test");
|
|
try {
|
|
const cfg: Partial<OpenClawConfig> = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "claude-3-5-sonnet" },
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = resolveConfiguredModelRef({
|
|
cfg: cfg as OpenClawConfig,
|
|
defaultProvider: "google",
|
|
defaultModel: "gemini-pro",
|
|
});
|
|
|
|
expect(result).toEqual({ provider: "google", model: "claude-3-5-sonnet" });
|
|
expect(
|
|
await warnLogs.findText(
|
|
'Model "claude-3-5-sonnet" specified without provider. Falling back to "google/claude-3-5-sonnet". Please use "google/claude-3-5-sonnet" in your config.',
|
|
),
|
|
).toBeDefined();
|
|
} finally {
|
|
warnLogs.cleanup();
|
|
}
|
|
});
|
|
|
|
it("sanitizes control characters in providerless-model warnings", async () => {
|
|
const warnLogs = createWarnLogCapture("openclaw-model-selection-test");
|
|
try {
|
|
const cfg: Partial<OpenClawConfig> = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "\u001B[31mclaude-3-5-sonnet\nspoof" },
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = resolveConfiguredModelRef({
|
|
cfg: cfg as OpenClawConfig,
|
|
defaultProvider: "google",
|
|
defaultModel: "gemini-pro",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
provider: "google",
|
|
model: "\u001B[31mclaude-3-5-sonnet\nspoof",
|
|
});
|
|
const warning = await warnLogs.findText('Falling back to "google/claude-3-5-sonnet"');
|
|
expect(warning).toContain('Falling back to "google/claude-3-5-sonnet"');
|
|
expect(warning).not.toContain("\u001B");
|
|
expect(warning).not.toContain("\n");
|
|
} finally {
|
|
warnLogs.cleanup();
|
|
}
|
|
});
|
|
|
|
it("infers a unique configured provider for bare default model strings", () => {
|
|
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "claude-opus-4-6" },
|
|
models: {
|
|
"anthropic/claude-opus-4-6": {},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.4",
|
|
});
|
|
|
|
expect(result).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
setLoggerOverride(null);
|
|
resetLogger();
|
|
}
|
|
});
|
|
|
|
it("normalizes bare configured default model strings with manifest policies", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "llama-fast" },
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
nvidia: {
|
|
models: [{ id: "llama-fast" }],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const result = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.4",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
provider: "nvidia",
|
|
model: "nvidia/llama-fast",
|
|
});
|
|
});
|
|
|
|
it("prefers slash-form aliases for configured default models", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "xiaomi/mimo-v2-pro-mit" },
|
|
models: {
|
|
"openai/xiaomi/mimo-v2-pro-mit": {
|
|
alias: "xiaomi/mimo-v2-pro-mit",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
});
|
|
|
|
expect(result).toEqual({ provider: "openai", model: "xiaomi/mimo-v2-pro-mit" });
|
|
});
|
|
|
|
it("prefers slash-form aliases before applying auth profile suffixes", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "xiaomi/mimo-v2-pro-mit@work" },
|
|
models: {
|
|
"openai/xiaomi/mimo-v2-pro-mit": {
|
|
alias: "xiaomi/mimo-v2-pro-mit",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
});
|
|
|
|
expect(result).toEqual({ provider: "openai", model: "xiaomi/mimo-v2-pro-mit" });
|
|
});
|
|
|
|
it("prefers exact aliases that contain auth-profile-like suffixes", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "gpt@prod" },
|
|
models: {
|
|
"openai/gpt-5.5": {
|
|
alias: "gpt@prod",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
});
|
|
|
|
expect(result).toEqual({ provider: "openai", model: "gpt-5.5" });
|
|
});
|
|
|
|
it("resolves provider-qualified defaults without normalizing every aliasless configured model", () => {
|
|
providerModelNormalizationMock.normalizeProviderModelIdWithRuntime.mockClear();
|
|
const models = Object.fromEntries(
|
|
Array.from({ length: 25 }, (_, index) => [`anthropic/claude-extra-${index}`, {}]),
|
|
);
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "openai/gpt-5.5" },
|
|
models,
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
});
|
|
|
|
expect(result).toEqual({ provider: "openai", model: "gpt-5.5" });
|
|
expect(
|
|
providerModelNormalizationMock.normalizeProviderModelIdWithRuntime,
|
|
).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should use default provider/model if config is empty", () => {
|
|
const cfg: Partial<OpenClawConfig> = {};
|
|
const result = resolveConfiguredModelRef({
|
|
cfg: cfg as OpenClawConfig,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-4",
|
|
});
|
|
expect(result).toEqual({ provider: "openai", model: "gpt-4" });
|
|
});
|
|
|
|
it("should prefer configured custom provider when default provider is not in models.providers", () => {
|
|
const cfg = createProviderWithModelsConfig("n1n", [
|
|
{
|
|
id: "gpt-5.4",
|
|
name: "GPT 5.4",
|
|
reasoning: false,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 4096,
|
|
},
|
|
]);
|
|
const result = resolveConfiguredRefForTest(cfg);
|
|
expect(result).toEqual({ provider: "n1n", model: "gpt-5.4" });
|
|
});
|
|
|
|
it("should keep default provider when it is in models.providers", () => {
|
|
const cfg = createProviderWithModelsConfig("anthropic", [
|
|
{
|
|
id: "claude-opus-4-6",
|
|
name: "Claude Opus 4.6",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 200000,
|
|
maxTokens: 4096,
|
|
},
|
|
]);
|
|
const result = resolveConfiguredRefForTest(cfg);
|
|
expect(result).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
|
|
});
|
|
|
|
it("can skip plugin-backed model normalization for display-only callers", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "google-vertex/gemini-3.1-flash-lite" },
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-opus-4-6",
|
|
allowPluginNormalization: false,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
provider: "google-vertex",
|
|
model: "gemini-3.1-flash-lite",
|
|
});
|
|
});
|
|
|
|
it("preserves exact configured provider ids before legacy alias normalization", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "modelstudio/qwen3.6-plus" },
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
modelstudio: {
|
|
api: "openai-completions",
|
|
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1",
|
|
models: [{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus" }],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
expect(
|
|
resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-opus-4-6",
|
|
}),
|
|
).toEqual({ provider: "modelstudio", model: "qwen3.6-plus" });
|
|
});
|
|
|
|
it("normalizes retired nested Gemini ids in exact configured provider refs", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "kilocode/google/gemini-3-pro-preview" },
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
kilocode: {
|
|
api: "openai-completions",
|
|
baseUrl: "https://kilocode.test/v1",
|
|
models: [{ id: "google/gemini-3-pro-preview", name: "Gemini 3 Pro" }],
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
expect(
|
|
resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-opus-4-6",
|
|
allowPluginNormalization: false,
|
|
}),
|
|
).toEqual({ provider: "kilocode", model: "google/gemini-3.1-pro-preview" });
|
|
});
|
|
|
|
it("preserves explicit provider ids when no exact foreign api owner is configured", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "modelstudio/qwen3.5-plus" },
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(
|
|
resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-opus-4-6",
|
|
}),
|
|
).toEqual({ provider: "modelstudio", model: "qwen3.5-plus" });
|
|
});
|
|
|
|
it("should fall back to hardcoded default when no custom providers have models", () => {
|
|
const cfg = createProviderWithModelsConfig("empty-provider", []);
|
|
const result = resolveConfiguredRefForTest(cfg);
|
|
expect(result).toEqual({ provider: "openai", model: "gpt-5.4" });
|
|
});
|
|
|
|
it("should warn when specified model cannot be resolved and falls back to default", async () => {
|
|
const warnLogs = createWarnLogCapture("openclaw-model-selection-test");
|
|
try {
|
|
const cfg: Partial<OpenClawConfig> = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "openai/" },
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = resolveConfiguredModelRef({
|
|
cfg: cfg as OpenClawConfig,
|
|
defaultProvider: "openai",
|
|
defaultModel: "gpt-5.4",
|
|
});
|
|
|
|
expect(result).toEqual({ provider: "openai", model: "gpt-5.4" });
|
|
expect(
|
|
await warnLogs.findText(
|
|
'Model "openai/" could not be resolved. Falling back to default "openai/gpt-5.4".',
|
|
),
|
|
).toBeDefined();
|
|
} finally {
|
|
warnLogs.cleanup();
|
|
}
|
|
});
|
|
|
|
it("resolves openrouter:auto through the canonical OpenRouter auto model", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "openrouter:auto" },
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
});
|
|
|
|
expect(result).toEqual({ provider: "openrouter", model: "openrouter/auto" });
|
|
});
|
|
|
|
it("resolves openrouter:free to the first configured concrete OpenRouter free model", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "openrouter:free" },
|
|
models: {
|
|
"openrouter/meta-llama/llama-3.3-70b-instruct:free": {},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
provider: "openrouter",
|
|
model: "meta-llama/llama-3.3-70b-instruct:free",
|
|
});
|
|
});
|
|
|
|
it("resolves openrouter:free from configured OpenRouter provider models when needed", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "openrouter:free" },
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
openrouter: {
|
|
baseUrl: "https://openrouter.ai/api/v1",
|
|
models: [
|
|
{
|
|
id: "deepseek/deepseek-r1-0528:free",
|
|
name: "DeepSeek R1 Free",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 8192,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: "anthropic",
|
|
defaultModel: "claude-sonnet-4-6",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
provider: "openrouter",
|
|
model: "deepseek/deepseek-r1-0528:free",
|
|
});
|
|
});
|
|
|
|
it("resolves openrouter:free through the allowed-model interactive path", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openrouter/meta-llama/llama-3.3-70b-instruct:free": {},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const catalog = [
|
|
{
|
|
provider: "openrouter",
|
|
id: "meta-llama/llama-3.3-70b-instruct:free",
|
|
name: "Llama 3.3 70B Free",
|
|
},
|
|
];
|
|
|
|
expect(
|
|
resolveAllowedModelRef({
|
|
cfg,
|
|
catalog,
|
|
raw: "openrouter:free",
|
|
defaultProvider: "anthropic",
|
|
}),
|
|
).toEqual({
|
|
ref: {
|
|
provider: "openrouter",
|
|
model: "meta-llama/llama-3.3-70b-instruct:free",
|
|
},
|
|
key: "openrouter/meta-llama/llama-3.3-70b-instruct:free",
|
|
});
|
|
});
|
|
|
|
it("treats raw openrouter:free allowlist entries as allowed in the legacy resolver path", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openrouter:free": {},
|
|
},
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
openrouter: {
|
|
baseUrl: "https://openrouter.ai/api/v1",
|
|
models: [
|
|
{
|
|
id: "deepseek/deepseek-r1-0528:free",
|
|
name: "DeepSeek R1 Free",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 8192,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const catalog = [
|
|
{
|
|
provider: "openrouter",
|
|
id: "deepseek/deepseek-r1-0528:free",
|
|
name: "DeepSeek R1 Free",
|
|
},
|
|
];
|
|
|
|
expect(
|
|
resolveAllowedModelRef({
|
|
cfg,
|
|
catalog,
|
|
raw: "openrouter:free",
|
|
defaultProvider: "anthropic",
|
|
}),
|
|
).toEqual({
|
|
ref: {
|
|
provider: "openrouter",
|
|
model: "deepseek/deepseek-r1-0528:free",
|
|
},
|
|
key: "openrouter/deepseek/deepseek-r1-0528:free",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveThinkingDefault", () => {
|
|
it("prefers per-model params.thinking over global thinkingDefault", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
thinkingDefault: "low",
|
|
models: {
|
|
"anthropic/claude-opus-4-6": {
|
|
params: { thinking: "high" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(resolveAnthropicOpusThinking(cfg)).toBe("high");
|
|
});
|
|
|
|
it("accepts legacy duplicated OpenRouter keys for per-model thinking", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openrouter/openrouter/hunter-alpha": {
|
|
params: { thinking: "high" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(
|
|
resolveThinkingDefault({
|
|
cfg,
|
|
provider: "openrouter",
|
|
model: "openrouter/hunter-alpha",
|
|
}),
|
|
).toBe("high");
|
|
});
|
|
|
|
it("accepts per-model params.thinking=adaptive", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"anthropic/claude-opus-4-6": {
|
|
params: { thinking: "adaptive" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(resolveAnthropicOpusThinking(cfg)).toBe("adaptive");
|
|
});
|
|
|
|
it("keeps thinking off by default for explicitly configured Anthropic Opus 4.7", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "anthropic/claude-opus-4-7" },
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(resolveAnthropicOpus47Thinking(cfg)).toBe("off");
|
|
});
|
|
|
|
it("uses bundled provider thinking defaults when no explicit config overrides them", () => {
|
|
const cfg = {} as OpenClawConfig;
|
|
|
|
expect(resolveAnthropicOpusThinking(cfg)).toBe("adaptive");
|
|
expect(
|
|
resolveThinkingDefault({
|
|
cfg,
|
|
provider: "amazon-bedrock",
|
|
model: "us.anthropic.claude-sonnet-4-6-v1:0",
|
|
catalog: [
|
|
{
|
|
provider: "amazon-bedrock",
|
|
id: "us.anthropic.claude-sonnet-4-6-v1:0",
|
|
name: "Claude Sonnet 4.6",
|
|
reasoning: true,
|
|
},
|
|
],
|
|
}),
|
|
).toBe("adaptive");
|
|
});
|
|
|
|
it("falls back to medium when no provider thinking policy is active", () => {
|
|
const cfg = {} as OpenClawConfig;
|
|
|
|
expect(
|
|
resolveThinkingDefault({
|
|
cfg,
|
|
provider: "custom-provider",
|
|
model: "custom-reasoning-model",
|
|
catalog: [
|
|
{
|
|
provider: "custom-provider",
|
|
id: "custom-reasoning-model",
|
|
name: "Custom Reasoning Model",
|
|
reasoning: true,
|
|
},
|
|
],
|
|
}),
|
|
).toBe("medium");
|
|
});
|
|
|
|
it("honors configured provider models that disable reasoning", () => {
|
|
const cfg = {
|
|
models: {
|
|
providers: {
|
|
google: {
|
|
api: "google-generative-ai",
|
|
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
|
models: [
|
|
{
|
|
id: "gemma-4-26b-a4b-it",
|
|
name: "Gemma 4 26B",
|
|
reasoning: false,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 32_000,
|
|
maxTokens: 8_192,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(
|
|
resolveThinkingDefault({
|
|
cfg,
|
|
provider: "google",
|
|
model: "gemma-4-26b-a4b-it",
|
|
}),
|
|
).toBe("off");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveDefaultModelForAgent", () => {
|
|
it("uses an agent primary model override before the global default", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "openai/gpt-5.4",
|
|
},
|
|
},
|
|
list: [
|
|
{
|
|
id: "main",
|
|
model: {
|
|
primary: "openai-codex/gpt-5.5",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(resolveDefaultModelForAgent({ cfg, agentId: "main" })).toEqual({
|
|
provider: "openai-codex",
|
|
model: "gpt-5.5",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("normalizeModelSelection", () => {
|
|
it("returns trimmed string for string input", () => {
|
|
expect(normalizeModelSelection("ollama/llama3.2:3b")).toBe("ollama/llama3.2:3b");
|
|
});
|
|
|
|
it("returns undefined for empty/whitespace string", () => {
|
|
expect(normalizeModelSelection("")).toBeUndefined();
|
|
expect(normalizeModelSelection(" ")).toBeUndefined();
|
|
});
|
|
|
|
it("extracts primary from object", () => {
|
|
expect(normalizeModelSelection({ primary: "google/gemini-2.5-flash" })).toBe(
|
|
"google/gemini-2.5-flash",
|
|
);
|
|
});
|
|
|
|
it("returns undefined for object without primary", () => {
|
|
expect(normalizeModelSelection({ fallbacks: ["a"] })).toBeUndefined();
|
|
expect(normalizeModelSelection({})).toBeUndefined();
|
|
});
|
|
|
|
it("returns undefined for null/undefined/number", () => {
|
|
expect(normalizeModelSelection(undefined)).toBeUndefined();
|
|
expect(normalizeModelSelection(null)).toBeUndefined();
|
|
expect(normalizeModelSelection(42)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("resolveSubagentConfiguredModelSelection", () => {
|
|
it("prefers agents.defaults.subagents.model over the agent primary model", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "anthropic/claude-sonnet-4-6" },
|
|
subagents: { model: "openai/gpt-5.4" },
|
|
},
|
|
list: [
|
|
{
|
|
id: "research",
|
|
model: { primary: "anthropic/claude-opus-4-6" },
|
|
},
|
|
],
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(resolveSubagentConfiguredModelSelection({ cfg, agentId: "research" })).toBe(
|
|
"openai/gpt-5.4",
|
|
);
|
|
});
|
|
|
|
it("still prefers agent subagents.model over the agent primary model", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "anthropic/claude-sonnet-4-6" },
|
|
subagents: { model: "openai/gpt-5.4" },
|
|
},
|
|
list: [
|
|
{
|
|
id: "research",
|
|
model: { primary: "anthropic/claude-opus-4-6" },
|
|
subagents: { model: "google/gemini-2.5-pro" },
|
|
},
|
|
],
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(resolveSubagentConfiguredModelSelection({ cfg, agentId: "research" })).toBe(
|
|
"google/gemini-2.5-pro",
|
|
);
|
|
});
|
|
|
|
it("keeps runtime policy attached to the configured default subagent model", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
subagents: { model: "anthropic/claude-sonnet-4-6" },
|
|
models: {
|
|
"anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } },
|
|
},
|
|
},
|
|
list: [{ id: "research", model: "anthropic/claude-opus-4-7" }],
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const resolved = resolveSubagentConfiguredModelSelection({ cfg, agentId: "research" });
|
|
|
|
expect(resolved).toBe("anthropic/claude-sonnet-4-6");
|
|
expect(
|
|
resolveAgentHarnessPolicy({
|
|
provider: "anthropic",
|
|
modelId: "claude-sonnet-4-6",
|
|
config: cfg,
|
|
}),
|
|
).toEqual({
|
|
runtime: "claude-cli",
|
|
runtimeSource: "model",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveSubagentSpawnModelSelection", () => {
|
|
it("resolves a model alias override to its full provider/model ref", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "anthropic/claude-sonnet-4-6" },
|
|
models: {
|
|
"anthropic/claude-opus-4-6": { alias: "opus" },
|
|
"openai/gpt-5.4": { alias: "gpt" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(
|
|
resolveSubagentSpawnModelSelection({ cfg, agentId: "main", modelOverride: "opus" }),
|
|
).toBe("anthropic/claude-opus-4-6");
|
|
});
|
|
|
|
it("resolves bare configured aliases with the target agent runtime default provider", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "openai/gpt-5.4" },
|
|
models: {
|
|
"claude-opus-4-6": { alias: "opus" },
|
|
},
|
|
},
|
|
list: [
|
|
{
|
|
id: "research",
|
|
model: "anthropic/claude-sonnet-4-6",
|
|
},
|
|
],
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(
|
|
resolveSubagentSpawnModelSelection({
|
|
cfg,
|
|
agentId: "research",
|
|
modelOverride: "OPUS",
|
|
}),
|
|
).toBe("anthropic/claude-opus-4-6");
|
|
});
|
|
|
|
it("resolves alias in configured subagent model", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "anthropic/claude-sonnet-4-6" },
|
|
models: {
|
|
"openai/gpt-5.4": { alias: "gpt" },
|
|
},
|
|
subagents: { model: "gpt" },
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(resolveSubagentSpawnModelSelection({ cfg, agentId: "main" })).toBe("openai/gpt-5.4");
|
|
});
|
|
|
|
it("passes through already-qualified provider/model refs unchanged", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "anthropic/claude-sonnet-4-6" },
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(
|
|
resolveSubagentSpawnModelSelection({
|
|
cfg,
|
|
agentId: "main",
|
|
modelOverride: "openai/gpt-5.4",
|
|
}),
|
|
).toBe("openai/gpt-5.4");
|
|
});
|
|
|
|
it("falls back to runtime default when no override or config", () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "anthropic/claude-sonnet-4-6" },
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(resolveSubagentSpawnModelSelection({ cfg, agentId: "main" })).toBe(
|
|
"anthropic/claude-sonnet-4-6",
|
|
);
|
|
});
|
|
});
|