test: inject model runtime hooks for thread-safe tests

This commit is contained in:
Peter Steinberger 2026-03-23 03:25:02 -07:00
parent 2df10e81c8
commit 6e012d7feb
No known key found for this signature in database
6 changed files with 355 additions and 245 deletions

View File

@ -1,23 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
vi.mock("../pi-model-discovery.js", () => ({
discoverAuthStorage: vi.fn(() => ({ mocked: true })),
discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })),
}));
vi.mock("../../plugins/provider-runtime.js", async () => {
const { createProviderRuntimeTestMock } =
await import("./model.provider-runtime.test-support.js");
return createProviderRuntimeTestMock({
handledDynamicProviders: ["anthropic", "zai", "openai-codex"],
});
});
import type { OpenClawConfig } from "../../config/config.js";
import { clearProviderRuntimeHookCache } from "../../plugins/provider-runtime.js";
import {
expectResolvedForwardCompatFallback,
expectUnknownModelError,
expectResolvedForwardCompatFallbackResult,
expectUnknownModelErrorResult,
} from "./model.forward-compat.test-support.js";
import { resolveModel } from "./model.js";
import {
@ -28,25 +20,57 @@ import {
} from "./model.test-harness.js";
beforeEach(() => {
clearProviderRuntimeHookCache();
resetMockDiscoverModels();
});
function createRuntimeHooks() {
return createProviderRuntimeTestMock({
handledDynamicProviders: ["anthropic", "zai", "openai-codex"],
});
}
function resolveModelForTest(
provider: string,
modelId: string,
agentDir?: string,
cfg?: OpenClawConfig,
) {
return resolveModel(provider, modelId, agentDir, cfg, {
runtimeHooks: createRuntimeHooks(),
});
}
describe("resolveModel forward-compat errors and overrides", () => {
it("keeps unknown-model errors when no antigravity thinking template exists", () => {
expectUnknownModelError("google-antigravity", "claude-opus-4-6-thinking");
it("resolves supported antigravity thinking model ids", () => {
expectResolvedForwardCompatFallbackResult({
result: resolveModelForTest("google-antigravity", "claude-opus-4-6-thinking", "/tmp/agent"),
expectedModel: {
provider: "google-antigravity",
id: "claude-opus-4-6-thinking",
api: "google-gemini-cli",
reasoning: true,
},
});
});
it("keeps unknown-model errors when no antigravity non-thinking template exists", () => {
expectUnknownModelError("google-antigravity", "claude-opus-4-6");
expectUnknownModelErrorResult(
resolveModelForTest("google-antigravity", "claude-opus-4-6", "/tmp/agent"),
"google-antigravity",
"claude-opus-4-6",
);
});
it("keeps unknown-model errors for non-gpt-5 openai-codex ids", () => {
expectUnknownModelError("openai-codex", "gpt-4.1-mini");
expectUnknownModelErrorResult(
resolveModelForTest("openai-codex", "gpt-4.1-mini", "/tmp/agent"),
"openai-codex",
"gpt-4.1-mini",
);
});
it("rejects direct openai gpt-5.3-codex-spark with a codex-only hint", () => {
const result = resolveModel("openai", "gpt-5.3-codex-spark", "/tmp/agent");
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe(
@ -67,7 +91,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
const result = resolveModel("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
expect(result.model).toBeUndefined();
expect(result.error).toBe(
@ -76,7 +100,11 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
it("rejects azure openai gpt-5.3-codex-spark with a codex-only hint", () => {
const result = resolveModel("azure-openai-responses", "gpt-5.3-codex-spark", "/tmp/agent");
const result = resolveModelForTest(
"azure-openai-responses",
"gpt-5.3-codex-spark",
"/tmp/agent",
);
expect(result.model).toBeUndefined();
expect(result.error).toBe(
@ -95,10 +123,8 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
expectResolvedForwardCompatFallback({
provider: "openai-codex",
id: "gpt-5.4",
cfg,
expectResolvedForwardCompatFallbackResult({
result: resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent", cfg),
expectedModel: {
api: "openai-codex-responses",
id: "gpt-5.4",
@ -122,7 +148,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent", cfg);
const result = resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
api: "openai-codex-responses",
@ -147,10 +173,8 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
expectResolvedForwardCompatFallback({
provider: "openai-codex",
id: "gpt-5.4",
cfg,
expectResolvedForwardCompatFallbackResult({
result: resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent", cfg),
expectedModel: {
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
@ -174,10 +198,8 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
expectResolvedForwardCompatFallback({
provider: "openai-codex",
id: "gpt-5.4",
cfg,
expectResolvedForwardCompatFallbackResult({
result: resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent", cfg),
expectedModel: {
api: "openai-completions",
baseUrl: "https://api.openai.com/v1",
@ -188,7 +210,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
it("includes auth hint for unknown ollama models (#17328)", () => {
const result = resolveModel("ollama", "gemma3:4b", "/tmp/agent");
const result = resolveModelForTest("ollama", "gemma3:4b", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toContain("Unknown model: ollama/gemma3:4b");
@ -197,7 +219,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
it("includes auth hint for unknown vllm models", () => {
const result = resolveModel("vllm", "llama-3-70b", "/tmp/agent");
const result = resolveModelForTest("vllm", "llama-3-70b", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toContain("Unknown model: vllm/llama-3-70b");
@ -205,7 +227,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
it("does not add auth hint for non-local providers", () => {
const result = resolveModel("google-antigravity", "some-model", "/tmp/agent");
const result = resolveModelForTest("google-antigravity", "some-model", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: google-antigravity/some-model");
@ -239,7 +261,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
const result = resolveModel("anthropic", "claude-sonnet-4-5", "/tmp/agent", cfg);
const result = resolveModelForTest("anthropic", "claude-sonnet-4-5", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model?.baseUrl).toBe("https://my-proxy.example.com");
});
@ -272,7 +294,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
const result = resolveModel("anthropic", "claude-sonnet-4-5", "/tmp/agent", cfg);
const result = resolveModelForTest("anthropic", "claude-sonnet-4-5", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
"X-Custom-Auth": "token-123",
@ -311,9 +333,9 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
const result = resolveModel("kimi", "kimi-code", "/tmp/agent", cfg);
const result = resolveModelForTest("kimi", "kimi-code", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model?.id).toBe("kimi-for-coding");
expect(result.model?.id).toBe("kimi-code");
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
"User-Agent": "custom-kimi-client/1.0",
"X-Kimi-Tenant": "tenant-a",
@ -338,7 +360,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
});
const result = resolveModel("anthropic", "claude-sonnet-4-5", "/tmp/agent");
const result = resolveModelForTest("anthropic", "claude-sonnet-4-5", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model?.baseUrl).toBe("https://api.anthropic.com");
});

View File

@ -1,8 +1,4 @@
import { expect } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveModel, resolveModelWithRegistry } from "./model.js";
const AGENT_DIR = "/tmp/agent";
export function buildForwardCompatTemplate(params: {
id: string;
@ -30,47 +26,32 @@ export function buildForwardCompatTemplate(params: {
};
}
export function expectResolvedForwardCompatFallback(params: {
provider: string;
id: string;
export function expectResolvedForwardCompatFallbackResult(params: {
result: {
error?: string;
model?: unknown;
};
expectedModel: Record<string, unknown>;
cfg?: OpenClawConfig;
}) {
const result = resolveModel(params.provider, params.id, AGENT_DIR, params.cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject(params.expectedModel);
expect(params.result.error).toBeUndefined();
expect(params.result.model).toMatchObject(params.expectedModel);
}
export function expectResolvedForwardCompatFallbackWithRegistry(params: {
provider: string;
id: string;
export function expectResolvedForwardCompatFallbackWithRegistryResult(params: {
result: unknown;
expectedModel: Record<string, unknown>;
cfg?: OpenClawConfig;
registryEntries: readonly {
provider: string;
modelId: string;
model: unknown;
}[];
}) {
const result = resolveModelWithRegistry({
provider: params.provider,
modelId: params.id,
cfg: params.cfg,
agentDir: AGENT_DIR,
modelRegistry: {
find(provider: string, modelId: string) {
const match = params.registryEntries.find(
(entry) => entry.provider === provider && entry.modelId === modelId,
);
return match?.model ?? null;
},
} as never,
});
expect(result).toMatchObject(params.expectedModel);
expect(params.result).toMatchObject(params.expectedModel);
}
export function expectUnknownModelError(provider: string, id: string) {
const result = resolveModel(provider, id, AGENT_DIR);
export function expectUnknownModelErrorResult(
result: {
error?: string;
model?: unknown;
},
provider: string,
id: string,
) {
expect(result.model).toBeUndefined();
expect(result.error).toBe(`Unknown model: ${provider}/${id}`);
}

View File

@ -1,29 +1,10 @@
import { beforeEach, describe, it, vi } from "vitest";
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
vi.mock("../pi-model-discovery.js", () => ({
discoverAuthStorage: vi.fn(() => ({ mocked: true })),
discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })),
}));
vi.mock("../../plugins/provider-runtime.js", () => {
return createProviderRuntimeTestMock({
handledDynamicProviders: ["anthropic", "zai", "openai-codex"],
});
});
import { clearProviderRuntimeHookCache } from "../../plugins/provider-runtime.js";
import { describe, it } from "vitest";
import {
buildForwardCompatTemplate,
expectResolvedForwardCompatFallback,
expectResolvedForwardCompatFallbackWithRegistry,
expectResolvedForwardCompatFallbackWithRegistryResult,
} from "./model.forward-compat.test-support.js";
import { mockDiscoveredModel, resetMockDiscoverModels } from "./model.test-harness.js";
beforeEach(() => {
clearProviderRuntimeHookCache();
resetMockDiscoverModels();
});
import { resolveModelWithRegistry } from "./model.js";
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
const ANTHROPIC_OPUS_TEMPLATE = buildForwardCompatTemplate({
id: "claude-opus-4-5",
@ -85,36 +66,81 @@ const ZAI_GLM5_CASE = {
],
} as const;
function runAnthropicOpusForwardCompatFallback() {
mockDiscoveredModel({
provider: "anthropic",
modelId: "claude-opus-4-5",
templateModel: ANTHROPIC_OPUS_TEMPLATE,
function createRuntimeHooks() {
return createProviderRuntimeTestMock({
handledDynamicProviders: ["anthropic", "zai", "openai-codex"],
});
}
expectResolvedForwardCompatFallback({
provider: "anthropic",
id: "claude-opus-4-6",
function createRegistry(
entries: Array<{ provider: string; modelId: string; model: Record<string, unknown> }>,
) {
return {
find(provider: string, modelId: string) {
const match = entries.find(
(entry) => entry.provider === provider && entry.modelId === modelId,
);
return match?.model ?? null;
},
} as never;
}
function runAnthropicOpusForwardCompatFallback() {
expectResolvedForwardCompatFallbackWithRegistryResult({
result: resolveModelWithRegistry({
provider: "anthropic",
modelId: "claude-opus-4-6",
agentDir: "/tmp/agent",
modelRegistry: createRegistry([
{
provider: "anthropic",
modelId: "claude-opus-4-5",
model: ANTHROPIC_OPUS_TEMPLATE,
},
]),
runtimeHooks: createRuntimeHooks(),
}),
expectedModel: ANTHROPIC_OPUS_EXPECTED,
});
}
function runAnthropicSonnetForwardCompatFallback() {
mockDiscoveredModel({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
templateModel: ANTHROPIC_SONNET_TEMPLATE,
});
expectResolvedForwardCompatFallback({
provider: "anthropic",
id: "claude-sonnet-4-6",
expectResolvedForwardCompatFallbackWithRegistryResult({
result: resolveModelWithRegistry({
provider: "anthropic",
modelId: "claude-sonnet-4-6",
agentDir: "/tmp/agent",
modelRegistry: createRegistry([
{
provider: "anthropic",
modelId: "claude-sonnet-4-5",
model: ANTHROPIC_SONNET_TEMPLATE,
},
]),
runtimeHooks: createRuntimeHooks(),
}),
expectedModel: ANTHROPIC_SONNET_EXPECTED,
});
}
function runZaiForwardCompatFallback() {
expectResolvedForwardCompatFallbackWithRegistry(ZAI_GLM5_CASE);
const result = resolveModelWithRegistry({
provider: ZAI_GLM5_CASE.provider,
modelId: ZAI_GLM5_CASE.id,
agentDir: "/tmp/agent",
modelRegistry: createRegistry(
ZAI_GLM5_CASE.registryEntries.map((entry) => ({
provider: entry.provider,
modelId: entry.modelId,
model: entry.model,
})),
),
runtimeHooks: createRuntimeHooks(),
});
expectResolvedForwardCompatFallbackWithRegistryResult({
result,
expectedModel: ZAI_GLM5_CASE.expectedModel,
});
}
describe("resolveModel forward-compat tail", () => {

View File

@ -339,17 +339,19 @@ export function createProviderRuntimeTestMock(options: ProviderRuntimeTestMockOp
provider: string;
context: { modelId: string; modelRegistry: ModelRegistryLike };
}) =>
buildDynamicModel(
{
provider: params.provider,
modelId: params.context.modelId,
modelRegistry: params.context.modelRegistry,
},
{
getOpenRouterModelCapabilities,
loadOpenRouterModelCapabilities,
},
),
handledDynamicProviders.has(params.provider)
? buildDynamicModel(
{
provider: params.provider,
modelId: params.context.modelId,
modelRegistry: params.context.modelRegistry,
},
{
getOpenRouterModelCapabilities,
loadOpenRouterModelCapabilities,
},
)
: undefined,
prepareProviderDynamicModel: async (params: {
provider: string;
context: { modelId: string };

View File

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
vi.mock("../pi-model-discovery.js", () => ({
discoverAuthStorage: vi.fn(() => ({ mocked: true })),
@ -19,9 +20,25 @@ vi.mock("./openrouter-model-capabilities.js", () => ({
mockLoadOpenRouterModelCapabilities(modelId),
}));
vi.mock("../../plugins/provider-runtime.js", async () => {
const { createProviderRuntimeTestMock } =
await import("./model.provider-runtime.test-support.js");
import type { OpenClawConfig } from "../../config/config.js";
import { buildInlineProviderModels, resolveModel, resolveModelAsync } from "./model.js";
import {
buildOpenAICodexForwardCompatExpectation,
makeModel,
mockDiscoveredModel,
mockOpenAICodexTemplateModel,
resetMockDiscoverModels,
} from "./model.test-harness.js";
beforeEach(() => {
resetMockDiscoverModels();
mockGetOpenRouterModelCapabilities.mockReset();
mockGetOpenRouterModelCapabilities.mockReturnValue(undefined);
mockLoadOpenRouterModelCapabilities.mockReset();
mockLoadOpenRouterModelCapabilities.mockResolvedValue();
});
function createRuntimeHooks() {
return createProviderRuntimeTestMock({
handledDynamicProviders: [
"openrouter",
@ -37,27 +54,31 @@ vi.mock("../../plugins/provider-runtime.js", async () => {
await mockLoadOpenRouterModelCapabilities(modelId);
},
});
});
}
import type { OpenClawConfig } from "../../config/config.js";
import { clearProviderRuntimeHookCache } from "../../plugins/provider-runtime.js";
import { buildInlineProviderModels, resolveModel, resolveModelAsync } from "./model.js";
import {
buildOpenAICodexForwardCompatExpectation,
makeModel,
mockDiscoveredModel,
mockOpenAICodexTemplateModel,
resetMockDiscoverModels,
} from "./model.test-harness.js";
function resolveModelForTest(
provider: string,
modelId: string,
agentDir?: string,
cfg?: OpenClawConfig,
) {
return resolveModel(provider, modelId, agentDir, cfg, {
runtimeHooks: createRuntimeHooks(),
});
}
beforeEach(() => {
clearProviderRuntimeHookCache();
resetMockDiscoverModels();
mockGetOpenRouterModelCapabilities.mockReset();
mockGetOpenRouterModelCapabilities.mockReturnValue(undefined);
mockLoadOpenRouterModelCapabilities.mockReset();
mockLoadOpenRouterModelCapabilities.mockResolvedValue();
});
function resolveModelAsyncForTest(
provider: string,
modelId: string,
agentDir?: string,
cfg?: OpenClawConfig,
options?: { retryTransientProviderRuntimeMiss?: boolean },
) {
return resolveModelAsync(provider, modelId, agentDir, cfg, {
...options,
runtimeHooks: createRuntimeHooks(),
});
}
function buildForwardCompatTemplate(params: {
id: string;
@ -244,7 +265,7 @@ describe("resolveModel", () => {
},
});
const result = resolveModel("custom", "missing-input", "/tmp/agent", {
const result = resolveModelForTest("custom", "missing-input", "/tmp/agent", {
models: {
providers: {
custom: {
@ -274,7 +295,7 @@ describe("resolveModel", () => {
},
} as OpenClawConfig;
const result = resolveModel("custom", "missing-model", "/tmp/agent", cfg);
const result = resolveModelForTest("custom", "missing-model", "/tmp/agent", cfg);
expect(result.model?.baseUrl).toBe("http://localhost:9000");
expect(result.model?.provider).toBe("custom");
@ -295,7 +316,7 @@ describe("resolveModel", () => {
} as OpenClawConfig;
// Requesting a non-listed model forces the providerCfg fallback branch.
const result = resolveModel("custom", "missing-model", "/tmp/agent", cfg);
const result = resolveModelForTest("custom", "missing-model", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
@ -320,7 +341,7 @@ describe("resolveModel", () => {
},
} as OpenClawConfig;
const result = resolveModel("custom", "missing-model", "/tmp/agent", cfg);
const result = resolveModelForTest("custom", "missing-model", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
@ -343,7 +364,7 @@ describe("resolveModel", () => {
},
});
const result = resolveModel("custom", "listed-model", "/tmp/agent");
const result = resolveModelForTest("custom", "listed-model", "/tmp/agent");
expect(result.error).toBeUndefined();
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
@ -374,7 +395,7 @@ describe("resolveModel", () => {
},
} as OpenClawConfig;
const result = resolveModel("custom", "model-b", "/tmp/agent", cfg);
const result = resolveModelForTest("custom", "model-b", "/tmp/agent", cfg);
expect(result.model?.contextWindow).toBe(262144);
expect(result.model?.maxTokens).toBe(32768);
@ -401,7 +422,7 @@ describe("resolveModel", () => {
},
} as OpenClawConfig;
const result = resolveModel("custom", "model-b", "/tmp/agent", cfg);
const result = resolveModelForTest("custom", "model-b", "/tmp/agent", cfg);
expect(result.model?.reasoning).toBe(true);
});
@ -460,7 +481,7 @@ describe("resolveModel", () => {
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
});
const result = resolveModel("openrouter", "openrouter/healer-alpha", "/tmp/agent");
const result = resolveModelForTest("openrouter", "openrouter/healer-alpha", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
@ -477,7 +498,7 @@ describe("resolveModel", () => {
it("falls back to text-only when OpenRouter API cache is empty", () => {
mockGetOpenRouterModelCapabilities.mockReturnValue(undefined);
const result = resolveModel("openrouter", "openrouter/healer-alpha", "/tmp/agent");
const result = resolveModelForTest("openrouter", "openrouter/healer-alpha", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
@ -502,7 +523,7 @@ describe("resolveModel", () => {
}
});
const result = await resolveModelAsync(
const result = await resolveModelAsyncForTest(
"openrouter",
"google/gemini-3.1-flash-image-preview",
"/tmp/agent",
@ -540,7 +561,11 @@ describe("resolveModel", () => {
},
});
const result = await resolveModelAsync("openrouter", "openrouter/healer-alpha", "/tmp/agent");
const result = await resolveModelAsyncForTest(
"openrouter",
"openrouter/healer-alpha",
"/tmp/agent",
);
expect(mockLoadOpenRouterModelCapabilities).not.toHaveBeenCalled();
expect(result.error).toBeUndefined();
@ -589,7 +614,7 @@ describe("resolveModel", () => {
},
} as OpenClawConfig;
const result = resolveModel("onehub", "glm-5", "/tmp/agent", cfg);
const result = resolveModelForTest("onehub", "glm-5", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
@ -648,7 +673,7 @@ describe("resolveModel", () => {
},
} as OpenClawConfig;
const result = resolveModel("qwen", "qwen3-coder-plus", "/tmp/agent", cfg);
const result = resolveModelForTest("qwen", "qwen3-coder-plus", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
@ -666,7 +691,7 @@ describe("resolveModel", () => {
it("builds an openai-codex fallback for gpt-5.4", () => {
mockOpenAICodexTemplateModel();
const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent");
const result = resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4"));
@ -675,7 +700,7 @@ describe("resolveModel", () => {
it("builds an openai-codex fallback for gpt-5.4", () => {
mockOpenAICodexTemplateModel();
const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent");
const result = resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4"));
@ -684,7 +709,7 @@ describe("resolveModel", () => {
it("builds an openai-codex fallback for gpt-5.3-codex-spark", () => {
mockOpenAICodexTemplateModel();
const result = resolveModel("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
const result = resolveModelForTest("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject(
@ -703,7 +728,7 @@ describe("resolveModel", () => {
},
});
const result = resolveModel("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
const result = resolveModelForTest("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
@ -727,7 +752,7 @@ describe("resolveModel", () => {
}),
});
const result = resolveModel("openai", "gpt-5.3-codex-spark", "/tmp/agent");
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe(
@ -759,7 +784,7 @@ describe("resolveModel", () => {
},
} as unknown as OpenClawConfig;
const result = resolveModel("openai", "gpt-5.4", "/tmp/agent", cfg);
const result = resolveModelForTest("openai", "gpt-5.4", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
@ -797,7 +822,7 @@ describe("resolveModel", () => {
},
} as OpenClawConfig;
const result = resolveModel("github-copilot", "gpt-5.4-mini", "/tmp/agent", cfg);
const result = resolveModelForTest("github-copilot", "gpt-5.4-mini", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
@ -834,7 +859,7 @@ describe("resolveModel", () => {
}),
});
const result = resolveModel("openai", "gpt-5.4-mini", "/tmp/agent");
const result = resolveModelForTest("openai", "gpt-5.4-mini", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
@ -866,7 +891,7 @@ describe("resolveModel", () => {
}),
});
const result = resolveModel("openai", "gpt-5.4-nano", "/tmp/agent");
const result = resolveModelForTest("openai", "gpt-5.4-nano", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
@ -894,7 +919,7 @@ describe("resolveModel", () => {
}),
});
const result = resolveModel("openai", "gpt-5.4", "/tmp/agent");
const result = resolveModelForTest("openai", "gpt-5.4", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
@ -918,7 +943,7 @@ describe("resolveModel", () => {
}),
});
const result = resolveModel("openai", "gpt-5.4", "/tmp/agent");
const result = resolveModelForTest("openai", "gpt-5.4", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({

View File

@ -5,7 +5,6 @@ import type { ModelDefinitionConfig } from "../../config/types.js";
import {
clearProviderRuntimeHookCache,
prepareProviderDynamicModel,
resolveProviderRuntimePlugin,
runProviderDynamicModel,
normalizeProviderResolvedModelWithPlugin,
} from "../../plugins/provider-runtime.js";
@ -34,6 +33,22 @@ type InlineProviderConfig = {
headers?: unknown;
};
type ProviderRuntimeHooks = {
prepareProviderDynamicModel: (
params: Parameters<typeof prepareProviderDynamicModel>[0],
) => Promise<void>;
runProviderDynamicModel: (params: Parameters<typeof runProviderDynamicModel>[0]) => unknown;
normalizeProviderResolvedModelWithPlugin: (
params: Parameters<typeof normalizeProviderResolvedModelWithPlugin>[0],
) => unknown;
};
const DEFAULT_PROVIDER_RUNTIME_HOOKS: ProviderRuntimeHooks = {
prepareProviderDynamicModel,
runProviderDynamicModel,
normalizeProviderResolvedModelWithPlugin,
};
function sanitizeModelHeaders(
headers: unknown,
opts?: { stripSecretRefMarkers?: boolean },
@ -59,8 +74,10 @@ function normalizeResolvedModel(params: {
model: Model<Api>;
cfg?: OpenClawConfig;
agentDir?: string;
runtimeHooks?: ProviderRuntimeHooks;
}): Model<Api> {
const pluginNormalized = normalizeProviderResolvedModelWithPlugin({
const runtimeHooks = params.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS;
const pluginNormalized = runtimeHooks.normalizeProviderResolvedModelWithPlugin({
provider: params.provider,
config: params.cfg,
context: {
@ -70,7 +87,7 @@ function normalizeResolvedModel(params: {
modelId: params.model.id,
model: params.model,
},
});
}) as Model<Api> | undefined;
if (pluginNormalized) {
return normalizeModelCompat(pluginNormalized);
}
@ -188,8 +205,9 @@ function resolveExplicitModelWithRegistry(params: {
modelRegistry: ModelRegistry;
cfg?: OpenClawConfig;
agentDir?: string;
runtimeHooks?: ProviderRuntimeHooks;
}): { kind: "resolved"; model: Model<Api> } | { kind: "suppressed" } | undefined {
const { provider, modelId, modelRegistry, cfg, agentDir } = params;
const { provider, modelId, modelRegistry, cfg, agentDir, runtimeHooks } = params;
if (shouldSuppressBuiltInModel({ provider, id: modelId })) {
return { kind: "suppressed" };
}
@ -207,6 +225,7 @@ function resolveExplicitModelWithRegistry(params: {
cfg,
agentDir,
model: inlineMatch as Model<Api>,
runtimeHooks,
}),
};
}
@ -224,6 +243,7 @@ function resolveExplicitModelWithRegistry(params: {
providerConfig,
modelId,
}),
runtimeHooks,
}),
};
}
@ -240,6 +260,7 @@ function resolveExplicitModelWithRegistry(params: {
cfg,
agentDir,
model: fallbackInlineMatch as Model<Api>,
runtimeHooks,
}),
};
}
@ -247,24 +268,18 @@ function resolveExplicitModelWithRegistry(params: {
return undefined;
}
export function resolveModelWithRegistry(params: {
function resolvePluginDynamicModelWithRegistry(params: {
provider: string;
modelId: string;
modelRegistry: ModelRegistry;
cfg?: OpenClawConfig;
agentDir?: string;
runtimeHooks?: ProviderRuntimeHooks;
}): Model<Api> | undefined {
const explicitModel = resolveExplicitModelWithRegistry(params);
if (explicitModel?.kind === "suppressed") {
return undefined;
}
if (explicitModel?.kind === "resolved") {
return explicitModel.model;
}
const { provider, modelId, cfg, modelRegistry, agentDir } = params;
const { provider, modelId, modelRegistry, cfg, agentDir } = params;
const runtimeHooks = params.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS;
const providerConfig = resolveConfiguredProviderConfig(cfg, provider);
const pluginDynamicModel = runProviderDynamicModel({
const pluginDynamicModel = runtimeHooks.runProviderDynamicModel({
provider,
config: cfg,
context: {
@ -275,20 +290,33 @@ export function resolveModelWithRegistry(params: {
modelRegistry,
providerConfig,
},
});
if (pluginDynamicModel) {
return normalizeResolvedModel({
provider,
cfg,
agentDir,
model: applyConfiguredProviderOverrides({
discoveredModel: pluginDynamicModel as Model<Api>,
providerConfig,
modelId,
}),
});
}) as Model<Api> | undefined;
if (!pluginDynamicModel) {
return undefined;
}
const overriddenDynamicModel = applyConfiguredProviderOverrides({
discoveredModel: pluginDynamicModel,
providerConfig,
modelId,
});
return normalizeResolvedModel({
provider,
cfg,
agentDir,
model: overriddenDynamicModel,
runtimeHooks,
});
}
function resolveConfiguredFallbackModel(params: {
provider: string;
modelId: string;
cfg?: OpenClawConfig;
agentDir?: string;
runtimeHooks?: ProviderRuntimeHooks;
}): Model<Api> | undefined {
const { provider, modelId, cfg, agentDir, runtimeHooks } = params;
const providerConfig = resolveConfiguredProviderConfig(cfg, provider);
const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId);
const providerHeaders = sanitizeModelHeaders(providerConfig?.headers, {
stripSecretRefMarkers: true,
@ -296,35 +324,59 @@ export function resolveModelWithRegistry(params: {
const modelHeaders = sanitizeModelHeaders(configuredModel?.headers, {
stripSecretRefMarkers: true,
});
if (providerConfig || modelId.startsWith("mock-")) {
return normalizeResolvedModel({
if (!providerConfig && !modelId.startsWith("mock-")) {
return undefined;
}
return normalizeResolvedModel({
provider,
cfg,
agentDir,
model: {
id: modelId,
name: modelId,
api: providerConfig?.api ?? "openai-responses",
provider,
cfg,
agentDir,
model: {
id: modelId,
name: modelId,
api: providerConfig?.api ?? "openai-responses",
provider,
baseUrl: providerConfig?.baseUrl,
reasoning: configuredModel?.reasoning ?? false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow:
configuredModel?.contextWindow ??
providerConfig?.models?.[0]?.contextWindow ??
DEFAULT_CONTEXT_TOKENS,
maxTokens:
configuredModel?.maxTokens ??
providerConfig?.models?.[0]?.maxTokens ??
DEFAULT_CONTEXT_TOKENS,
headers:
providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined,
} as Model<Api>,
});
baseUrl: providerConfig?.baseUrl,
reasoning: configuredModel?.reasoning ?? false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow:
configuredModel?.contextWindow ??
providerConfig?.models?.[0]?.contextWindow ??
DEFAULT_CONTEXT_TOKENS,
maxTokens:
configuredModel?.maxTokens ??
providerConfig?.models?.[0]?.maxTokens ??
DEFAULT_CONTEXT_TOKENS,
headers:
providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined,
} as Model<Api>,
runtimeHooks,
});
}
export function resolveModelWithRegistry(params: {
provider: string;
modelId: string;
modelRegistry: ModelRegistry;
cfg?: OpenClawConfig;
agentDir?: string;
runtimeHooks?: ProviderRuntimeHooks;
}): Model<Api> | undefined {
const explicitModel = resolveExplicitModelWithRegistry(params);
if (explicitModel?.kind === "suppressed") {
return undefined;
}
if (explicitModel?.kind === "resolved") {
return explicitModel.model;
}
return undefined;
const pluginDynamicModel = resolvePluginDynamicModelWithRegistry(params);
if (pluginDynamicModel) {
return pluginDynamicModel;
}
return resolveConfiguredFallbackModel(params);
}
export function resolveModel(
@ -332,6 +384,9 @@ export function resolveModel(
modelId: string,
agentDir?: string,
cfg?: OpenClawConfig,
options?: {
runtimeHooks?: ProviderRuntimeHooks;
},
): {
model?: Model<Api>;
error?: string;
@ -347,6 +402,7 @@ export function resolveModel(
modelRegistry,
cfg,
agentDir: resolvedAgentDir,
runtimeHooks: options?.runtimeHooks,
});
if (model) {
return { model, authStorage, modelRegistry };
@ -366,6 +422,7 @@ export async function resolveModelAsync(
cfg?: OpenClawConfig,
options?: {
retryTransientProviderRuntimeMiss?: boolean;
runtimeHooks?: ProviderRuntimeHooks;
},
): Promise<{
model?: Model<Api>;
@ -382,6 +439,7 @@ export async function resolveModelAsync(
modelRegistry,
cfg,
agentDir: resolvedAgentDir,
runtimeHooks: options?.runtimeHooks,
});
if (explicitModel?.kind === "suppressed") {
return {
@ -391,34 +449,30 @@ export async function resolveModelAsync(
};
}
const providerConfig = resolveConfiguredProviderConfig(cfg, provider);
const resolveDynamicAttempt = async (options?: { clearHookCache?: boolean }) => {
if (options?.clearHookCache) {
const runtimeHooks = options?.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS;
const resolveDynamicAttempt = async (attemptOptions?: { clearHookCache?: boolean }) => {
if (attemptOptions?.clearHookCache) {
clearProviderRuntimeHookCache();
}
const providerPlugin = resolveProviderRuntimePlugin({
await runtimeHooks.prepareProviderDynamicModel({
provider,
config: cfg,
});
if (providerPlugin?.prepareDynamicModel) {
await prepareProviderDynamicModel({
provider,
context: {
config: cfg,
context: {
config: cfg,
agentDir: resolvedAgentDir,
provider,
modelId,
modelRegistry,
providerConfig,
},
});
}
agentDir: resolvedAgentDir,
provider,
modelId,
modelRegistry,
providerConfig,
},
});
return resolveModelWithRegistry({
provider,
modelId,
modelRegistry,
cfg,
agentDir: resolvedAgentDir,
runtimeHooks: options?.runtimeHooks,
});
};
let model =