diff --git a/src/agents/pi-embedded-runner/model.forward-compat.errors-and-overrides.test.ts b/src/agents/pi-embedded-runner/model.forward-compat.errors-and-overrides.test.ts index a4efd1b47e4..28c6acf71b2 100644 --- a/src/agents/pi-embedded-runner/model.forward-compat.errors-and-overrides.test.ts +++ b/src/agents/pi-embedded-runner/model.forward-compat.errors-and-overrides.test.ts @@ -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 }).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 }).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"); }); diff --git a/src/agents/pi-embedded-runner/model.forward-compat.test-support.ts b/src/agents/pi-embedded-runner/model.forward-compat.test-support.ts index 353c031e1de..ed9e67c5f91 100644 --- a/src/agents/pi-embedded-runner/model.forward-compat.test-support.ts +++ b/src/agents/pi-embedded-runner/model.forward-compat.test-support.ts @@ -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; - 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; - 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}`); } diff --git a/src/agents/pi-embedded-runner/model.forward-compat.test.ts b/src/agents/pi-embedded-runner/model.forward-compat.test.ts index 37b9bf4997f..9cca216dad1 100644 --- a/src/agents/pi-embedded-runner/model.forward-compat.test.ts +++ b/src/agents/pi-embedded-runner/model.forward-compat.test.ts @@ -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 }>, +) { + 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", () => { diff --git a/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts b/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts index fd2ff06010e..d8ef2b9be15 100644 --- a/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts +++ b/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts @@ -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 }; diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 4395c112716..69bfc3192d7 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -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 }).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 }).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 }).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({ diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 0b1253fa14a..c30c531bad1 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -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[0], + ) => Promise; + runProviderDynamicModel: (params: Parameters[0]) => unknown; + normalizeProviderResolvedModelWithPlugin: ( + params: Parameters[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; cfg?: OpenClawConfig; agentDir?: string; + runtimeHooks?: ProviderRuntimeHooks; }): Model { - 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 | 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 } | { 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, + runtimeHooks, }), }; } @@ -224,6 +243,7 @@ function resolveExplicitModelWithRegistry(params: { providerConfig, modelId, }), + runtimeHooks, }), }; } @@ -240,6 +260,7 @@ function resolveExplicitModelWithRegistry(params: { cfg, agentDir, model: fallbackInlineMatch as Model, + 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 | 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, - providerConfig, - modelId, - }), - }); + }) as Model | 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 | 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, - }); + 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, + runtimeHooks, + }); +} + +export function resolveModelWithRegistry(params: { + provider: string; + modelId: string; + modelRegistry: ModelRegistry; + cfg?: OpenClawConfig; + agentDir?: string; + runtimeHooks?: ProviderRuntimeHooks; +}): Model | 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; 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; @@ -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 =