refactor(agents): resolve runtime models through prepared owner (#130007)

* refactor(agents): resolve runtime models through prepared owner

* chore: tighten agent inventory assertion baseline
This commit is contained in:
Peter Steinberger
2026-08-26 02:20:05 -07:00
committed by GitHub
parent 33a24b6d8e
commit 0102fcb7dd
8 changed files with 521 additions and 568 deletions
+1 -1
View File
@@ -2084,7 +2084,7 @@ src/agents/tool-search-runtime.ts 6
src/agents/tool-search-transcript.ts 3
src/agents/tool-search.ts 1
src/agents/tools-effective-inventory-build.ts 2
src/agents/tools-effective-inventory.ts 5
src/agents/tools-effective-inventory.ts 4
src/agents/tools/agent-step.ts 6
src/agents/tools/agents-wait-tool.ts 1
src/agents/tools/ask-user-tool-normalization.ts 3
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ModelProviderConfig, OpenClawConfig } from "../../config/config.js";
import { discoverModels } from "../agent-model-discovery.js";
import type { PreparedModelRuntimeSnapshot } from "../prepared-model-runtime.js";
import { buildInlineProviderModels } from "./model.inline-provider.js";
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
vi.mock("../../plugins/provider-runtime.js", () => ({
@@ -105,7 +106,7 @@ vi.mock("../prepared-model-runtime.js", async () => {
}),
modelCatalog: { entries: [], routeVariants: [] },
configuredRuntimeModels: [],
inlineProviderModels: [],
inlineProviderModels: buildInlineProviderModels(config.models?.providers ?? {}),
createStores: () => {
const authStorage = discovery.discoverAuthStorage(input.agentDir);
const modelRegistry = discovery.discoverModels(authStorage, input.agentDir, {
@@ -135,7 +136,7 @@ import {
expectResolvedForwardCompatFallbackResult,
expectUnknownModelErrorResult,
} from "./model.forward-compat.test-support.js";
import { resolveModel } from "./model.js";
import { resolveModelAsync } from "./model.js";
import {
buildOpenAICodexForwardCompatExpectation,
makeModel,
@@ -156,13 +157,13 @@ function createRuntimeHooks() {
});
}
function resolveModelForTest(
async function resolveModelForTest(
provider: string,
modelId: string,
agentDir?: string,
cfg?: OpenClawConfig,
) {
return resolveModel(provider, modelId, agentDir, cfg, {
return await resolveModelAsync(provider, modelId, agentDir, cfg, {
runtimeHooks: createRuntimeHooks(),
});
}
@@ -182,7 +183,7 @@ function createAnthropicTemplateModel() {
};
}
function resolveAnthropicModelWithProviderOverrides(overrides: Partial<ModelProviderConfig>) {
async function resolveAnthropicModelWithProviderOverrides(overrides: Partial<ModelProviderConfig>) {
// Provider config overrides must merge onto discovered template models without
// losing the template's API, cost, and capability metadata.
mockDiscoveredModel(discoverModels, {
@@ -191,7 +192,7 @@ function resolveAnthropicModelWithProviderOverrides(overrides: Partial<ModelProv
templateModel: createAnthropicTemplateModel(),
});
return resolveModelForTest("anthropic", "claude-sonnet-4-5", "/tmp/agent", {
return await resolveModelForTest("anthropic", "claude-sonnet-4-5", "/tmp/agent", {
models: {
providers: {
anthropic: overrides,
@@ -201,9 +202,13 @@ function resolveAnthropicModelWithProviderOverrides(overrides: Partial<ModelProv
}
describe("resolveModel forward-compat errors and overrides", () => {
it("builds a forward-compat fallback for supported antigravity thinking ids", () => {
it("builds a forward-compat fallback for supported antigravity thinking ids", async () => {
expectResolvedForwardCompatFallbackResult({
result: resolveModelForTest("google-antigravity", "claude-opus-4-6-thinking", "/tmp/agent"),
result: await resolveModelForTest(
"google-antigravity",
"claude-opus-4-6-thinking",
"/tmp/agent",
),
expectedModel: {
api: "google-gemini-cli",
baseUrl: "https://cloudcode-pa.googleapis.com",
@@ -214,24 +219,24 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
});
it("keeps unknown-model errors when no antigravity non-thinking template exists", () => {
it("keeps unknown-model errors when no antigravity non-thinking template exists", async () => {
expectUnknownModelErrorResult(
resolveModelForTest("google-antigravity", "claude-opus-4-6", "/tmp/agent"),
await 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 ids", () => {
it("keeps unknown-model errors for non-gpt-5 openai ids", async () => {
expectUnknownModelErrorResult(
resolveModelForTest("openai", "gpt-4.1-mini", "/tmp/agent"),
await resolveModelForTest("openai", "gpt-4.1-mini", "/tmp/agent"),
"openai",
"gpt-4.1-mini",
);
});
it("rejects direct openai gpt-5.3-codex-spark with a codex-only hint", () => {
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent");
it("rejects direct openai gpt-5.3-codex-spark with a codex-only hint", async () => {
const result = await resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe(
@@ -239,7 +244,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
);
});
it("keeps suppressed openai gpt-5.3-codex-spark from falling through provider fallback", () => {
it("keeps suppressed openai gpt-5.3-codex-spark from falling through provider fallback", async () => {
const cfg = {
models: {
providers: {
@@ -252,7 +257,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
const result = await resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
expect(result.model).toBeUndefined();
expect(result.error).toBe(
@@ -260,7 +265,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
);
});
it("resolves suppressed openai gpt-5.3-codex-spark through ChatGPT/Codex routing", () => {
it("resolves suppressed openai gpt-5.3-codex-spark through ChatGPT/Codex routing", async () => {
mockOpenAICodexTemplateModel(discoverModels);
const cfg = {
@@ -273,7 +278,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
const result = await resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject(
@@ -281,7 +286,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
);
});
it("resolves suppressed openai gpt-5.3-codex-spark through model-scoped Codex runtime", () => {
it("resolves suppressed openai gpt-5.3-codex-spark through model-scoped Codex runtime", async () => {
mockOpenAICodexTemplateModel(discoverModels);
const cfg: OpenClawConfig = {
@@ -295,7 +300,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
},
};
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
const result = await resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject(
@@ -303,7 +308,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
);
});
it("keeps model-scoped Codex runtime blocked for explicit OpenAI API-key provider config", () => {
it("keeps model-scoped Codex runtime blocked for explicit OpenAI API-key provider config", async () => {
mockOpenAICodexTemplateModel(discoverModels);
const cfg: OpenClawConfig = {
@@ -327,13 +332,13 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
},
};
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
const result = await resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
expect(result.model).toBeUndefined();
expect(result.error).toContain("OpenAI API-key auth cannot use this model");
});
it("keeps suppressed stale direct openai gpt-5.3-codex-spark catalog rows blocked", () => {
it("keeps suppressed stale direct openai gpt-5.3-codex-spark catalog rows blocked", async () => {
mockDiscoveredModel(discoverModels, {
provider: "openai",
modelId: "gpt-5.3-codex-spark",
@@ -345,13 +350,13 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
});
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent");
const result = await resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toContain("ChatGPT/Codex OAuth");
});
it("keeps stale persisted openai gpt-5.3-codex-spark rows blocked without transport metadata", () => {
it("keeps stale persisted openai gpt-5.3-codex-spark rows blocked without transport metadata", async () => {
mockDiscoveredModel(discoverModels, {
provider: "openai",
modelId: "gpt-5.3-codex-spark",
@@ -361,13 +366,13 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
});
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent");
const result = await resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toContain("ChatGPT/Codex OAuth");
});
it("keeps configured custom openai gpt-5.3-codex-spark rows when not direct OpenAI API", () => {
it("keeps configured custom openai gpt-5.3-codex-spark rows when not direct OpenAI API", async () => {
const cfg = {
models: {
providers: {
@@ -386,7 +391,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
const result = await resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
@@ -397,7 +402,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
});
it("rejects configured direct openai gpt-5.3-codex-spark rows", () => {
it("rejects configured direct openai gpt-5.3-codex-spark rows", async () => {
const cfg = {
models: {
providers: {
@@ -416,14 +421,14 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
const result = await resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
expect(result.model).toBeUndefined();
expect(result.error).toContain("ChatGPT/Codex OAuth");
expect(result.error).toContain("OpenAI API-key auth cannot use this model");
});
it("keeps configured custom openai gpt-5.3-codex-spark rows that omit api", () => {
it("keeps configured custom openai gpt-5.3-codex-spark rows that omit api", async () => {
const cfg = {
models: {
providers: {
@@ -440,7 +445,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
const result = await resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
@@ -451,7 +456,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
});
it("keeps registry openai gpt-5.3-codex-spark rows on custom provider endpoints", () => {
it("keeps registry openai gpt-5.3-codex-spark rows on custom provider endpoints", async () => {
mockDiscoveredModel(discoverModels, {
provider: "openai",
modelId: "gpt-5.3-codex-spark",
@@ -473,7 +478,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
const result = await resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
@@ -484,7 +489,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
});
it("checks registry baseUrl before suppressing openai gpt-5.3-codex-spark rows", () => {
it("checks registry baseUrl before suppressing openai gpt-5.3-codex-spark rows", async () => {
mockDiscoveredModel(discoverModels, {
provider: "openai",
modelId: "gpt-5.3-codex-spark",
@@ -496,7 +501,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
});
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent");
const result = await resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
@@ -507,8 +512,8 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
});
it("rejects azure openai gpt-5.3-codex-spark with a codex-only hint", () => {
const result = resolveModelForTest(
it("rejects azure openai gpt-5.3-codex-spark with a codex-only hint", async () => {
const result = await resolveModelForTest(
"azure-openai-responses",
"gpt-5.3-codex-spark",
"/tmp/agent",
@@ -520,7 +525,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
);
});
it("rejects azure openai gpt-5.3-codex-spark through the openai owner when azure config has no matching model row", () => {
it("rejects azure openai gpt-5.3-codex-spark through the openai owner when azure config has no matching model row", async () => {
const cfg = {
models: {
providers: {
@@ -533,7 +538,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} satisfies OpenClawConfig;
const result = resolveModelForTest(
const result = await resolveModelForTest(
"azure-openai-responses",
"gpt-5.3-codex-spark",
"/tmp/agent",
@@ -546,7 +551,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
);
});
it("keeps unconditional codex-only suppression on the openai owner when azure config has a matching model row", () => {
it("keeps unconditional codex-only suppression on the openai owner when azure config has a matching model row", async () => {
const cfg = {
models: {
providers: {
@@ -559,7 +564,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} satisfies OpenClawConfig;
const result = resolveModelForTest(
const result = await resolveModelForTest(
"azure-openai-responses",
"gpt-5.3-codex-spark",
"/tmp/agent",
@@ -572,7 +577,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
);
});
it("keeps provider-level azure deployment names on the azure owner", () => {
it("keeps provider-level azure deployment names on the azure owner", async () => {
const cfg = {
models: {
providers: {
@@ -585,7 +590,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} satisfies OpenClawConfig;
const result = resolveModelForTest(
const result = await resolveModelForTest(
"azure-openai-responses",
"customer-gpt-deployment",
"/tmp/agent",
@@ -601,7 +606,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
});
it("uses retained azure alias transport defaults for provider-level deployment names", () => {
it("uses retained azure alias transport defaults for provider-level deployment names", async () => {
const cfg = {
models: {
providers: {
@@ -613,7 +618,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} satisfies OpenClawConfig;
const result = resolveModelForTest(
const result = await resolveModelForTest(
"azure-openai-responses",
"customer-gpt-deployment",
"/tmp/agent",
@@ -629,7 +634,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
});
it("rejects provider-level azure codex-only aliases through the openai owner", () => {
it("rejects provider-level azure codex-only aliases through the openai owner", async () => {
const cfg = {
models: {
providers: {
@@ -642,7 +647,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} satisfies OpenClawConfig;
const result = resolveModelForTest(
const result = await resolveModelForTest(
"azure-openai-responses",
"gpt-5.3-codex-spark",
"/tmp/agent",
@@ -655,7 +660,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
);
});
it("uses codex fallback even when openai provider is configured", () => {
it("uses codex fallback even when openai provider is configured", async () => {
const cfg: OpenClawConfig = {
models: {
providers: {
@@ -668,7 +673,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
} as unknown as OpenClawConfig;
expectResolvedForwardCompatFallbackResult({
result: resolveModelForTest("openai", "gpt-5.4", "/tmp/agent", cfg),
result: await resolveModelForTest("openai", "gpt-5.4", "/tmp/agent", cfg),
expectedModel: {
api: "openai-chatgpt-responses",
id: "gpt-5.4",
@@ -677,7 +682,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
});
it("uses codex fallback when inline model omits api (#39682)", () => {
it("uses codex fallback when inline model omits api (#39682)", async () => {
mockOpenAICodexTemplateModel(discoverModels);
const cfg: OpenClawConfig = {
@@ -692,7 +697,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("openai", "gpt-5.4", "/tmp/agent", cfg);
const result = await resolveModelForTest("openai", "gpt-5.4", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model?.api).toBe("openai-chatgpt-responses");
expect(result.model?.baseUrl).toBe("https://custom.example.com");
@@ -703,7 +708,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
});
it("keeps openai gpt-5.4 responses overrides on the OpenAI API transport", () => {
it("keeps openai gpt-5.4 responses overrides on the OpenAI API transport", async () => {
mockOpenAICodexTemplateModel(discoverModels);
const cfg: OpenClawConfig = {
@@ -718,7 +723,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
} as unknown as OpenClawConfig;
expectResolvedForwardCompatFallbackResult({
result: resolveModelForTest("openai", "gpt-5.4", "/tmp/agent", cfg),
result: await resolveModelForTest("openai", "gpt-5.4", "/tmp/agent", cfg),
expectedModel: {
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
@@ -728,7 +733,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
});
it("normalizes openai gpt-5.4 completions overrides to the OpenAI API transport", () => {
it("normalizes openai gpt-5.4 completions overrides to the OpenAI API transport", async () => {
mockOpenAICodexTemplateModel(discoverModels);
const cfg: OpenClawConfig = {
@@ -743,7 +748,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
} as unknown as OpenClawConfig;
expectResolvedForwardCompatFallbackResult({
result: resolveModelForTest("openai", "gpt-5.4", "/tmp/agent", cfg),
result: await resolveModelForTest("openai", "gpt-5.4", "/tmp/agent", cfg),
expectedModel: {
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
@@ -753,8 +758,8 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
});
it("includes auth hint for unknown ollama models (#17328)", () => {
const result = resolveModelForTest("ollama", "gemma3:4b", "/tmp/agent");
it("includes auth hint for unknown ollama models (#17328)", async () => {
const result = await resolveModelForTest("ollama", "gemma3:4b", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toContain("Unknown model: ollama/gemma3:4b");
@@ -762,31 +767,31 @@ describe("resolveModel forward-compat errors and overrides", () => {
expect(result.error).toContain("docs.openclaw.ai/providers/ollama");
});
it("includes auth hint for unknown vllm models", () => {
const result = resolveModelForTest("vllm", "llama-3-70b", "/tmp/agent");
it("includes auth hint for unknown vllm models", async () => {
const result = await resolveModelForTest("vllm", "llama-3-70b", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toContain("Unknown model: vllm/llama-3-70b");
expect(result.error).toContain("VLLM_API_KEY");
});
it("does not add auth hint for non-local providers", () => {
const result = resolveModelForTest("google-antigravity", "some-model", "/tmp/agent");
it("does not add auth hint for non-local providers", async () => {
const result = await resolveModelForTest("google-antigravity", "some-model", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: google-antigravity/some-model");
});
it("applies provider baseUrl override to registry-found models", () => {
const result = resolveAnthropicModelWithProviderOverrides({
it("applies provider baseUrl override to registry-found models", async () => {
const result = await resolveAnthropicModelWithProviderOverrides({
baseUrl: "https://my-proxy.example.com",
});
expect(result.error).toBeUndefined();
expect(result.model?.baseUrl).toBe("https://my-proxy.example.com");
});
it("applies provider headers override to registry-found models", () => {
const result = resolveAnthropicModelWithProviderOverrides({
it("applies provider headers override to registry-found models", async () => {
const result = await resolveAnthropicModelWithProviderOverrides({
headers: { "X-Custom-Auth": "token-123" },
});
expect(result.error).toBeUndefined();
@@ -795,7 +800,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
});
it("lets provider config override registry-found kimi user agent headers", () => {
it("lets provider config override registry-found kimi user agent headers", async () => {
mockDiscoveredModel(discoverModels, {
provider: "kimi",
modelId: "kimi-code",
@@ -827,7 +832,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
} as unknown as OpenClawConfig;
const result = resolveModelForTest("kimi", "kimi-code", "/tmp/agent", cfg);
const result = await resolveModelForTest("kimi", "kimi-code", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model?.id).toBe("kimi-code");
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
@@ -836,7 +841,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
});
});
it("does not override when no provider config exists", () => {
it("does not override when no provider config exists", async () => {
mockDiscoveredModel(discoverModels, {
provider: "anthropic",
modelId: "claude-sonnet-4-6",
@@ -854,7 +859,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
},
});
const result = resolveModelForTest("anthropic", "claude-sonnet-4-6", "/tmp/agent");
const result = await resolveModelForTest("anthropic", "claude-sonnet-4-6", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model?.baseUrl).toBe("https://api.anthropic.com");
});
@@ -1,13 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js";
import { withPluginRuntimeGenerationScope } from "../../plugins/runtime/generation-scope.js";
import {
createModelGenerationFixture,
publishCurrentModelGeneration,
resetModelGenerationFixtureState,
} from "./model.generation-scope.test-support.js";
import { resolveModel, resolveModelAsync } from "./model.js";
import { resolveModelAsync } from "./model.js";
async function resolveGeneration(generation: ReturnType<typeof createModelGenerationFixture>) {
const { preparedModelRuntime } = generation;
@@ -117,31 +116,4 @@ describe("model runtime generation scope", () => {
});
expect(generationB.resolveDynamicModel).not.toHaveBeenCalled();
});
it("keeps synchronous resolution on the exact scoped generation", () => {
const config = {} satisfies OpenClawConfig;
const generationA = createModelGenerationFixture({ config, label: "a" });
const generationB = createModelGenerationFixture({ config, label: "b" });
publishCurrentModelGeneration(generationB);
const stores = generationA.preparedModelRuntime.createStores();
const result = withPluginRuntimeGenerationScope(generationA.preparedModelRuntime, () =>
resolveModel(
generationA.requestProvider,
generationA.modelId,
generationA.preparedModelRuntime.agentDir,
config,
{
...stores,
workspaceDir: generationA.preparedModelRuntime.workspaceDir,
},
),
);
expect(result.model).toMatchObject({
provider: generationA.provider,
name: "Runtime A",
});
expect(generationB.resolveDynamicModel).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
-102
View File
@@ -10,7 +10,6 @@ import { modelKey } from "../model-ref-shared.js";
import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js";
import { buildSuppressedBuiltInModelError } from "../model-suppression.js";
import {
PreparedModelRuntimeOwnerNotPublishedError,
getPreparedModelRuntimeSnapshot,
loadPreparedModelRuntimeSnapshot,
type PreparedModelRuntimeSnapshot,
@@ -111,107 +110,6 @@ function resolvePreparedAgentSnapshot(
return getPreparedModelRuntimeSnapshot({ ...base, workspaceDir: derivedWorkspaceDir });
}
export function resolveModel(
provider: string,
modelId: string,
agentDir?: string,
cfg?: OpenClawConfig,
options?: CommonModelResolutionOptions,
): {
model?: Model;
error?: string;
authStorage: AuthStorage;
modelRegistry: ModelRegistry;
} {
const resolvedAgentDir = agentDir ?? resolveDefaultAgentDir(cfg ?? {});
const derivedWorkspaceDir = resolveModelWorkspaceDir(
cfg,
options?.workspaceDir,
options?.agentId,
);
const preparedSnapshot =
!options?.authStorage || !options?.modelRegistry
? resolvePreparedAgentSnapshot(
resolvedAgentDir,
cfg,
options?.workspaceDir,
derivedWorkspaceDir,
options?.agentId,
)
: undefined;
if ((!options?.authStorage || !options?.modelRegistry) && !preparedSnapshot) {
// Synchronous callers must enter through a lifecycle that already published discovery.
// Falling back to an empty registry turns a stale/pending generation into a false model miss.
throw new PreparedModelRuntimeOwnerNotPublishedError(
`prepared model runtime is not published for synchronous model resolution (${resolvedAgentDir}); use resolveModelAsync before lifecycle publication`,
);
}
const preparedModelRuntime = preparedSnapshot;
const resolve = () => {
const workspaceDir =
options?.workspaceDir ?? preparedModelRuntime?.workspaceDir ?? derivedWorkspaceDir;
const normalizedRef = normalizeProviderModelRef({ provider, modelId, cfg, workspaceDir });
const preparedStores =
!options?.authStorage || !options?.modelRegistry
? preparedModelRuntime?.createStores()
: undefined;
const authStorage = options?.authStorage ?? preparedStores!.authStorage;
const modelRegistry =
options?.modelRegistry ??
(options?.authStorage
? preparedStores!.modelRegistry.fork(authStorage)
: preparedStores!.modelRegistry);
const runtimeHooks = resolveRuntimeHooks(options);
let staticCatalogResolved = false;
let staticCatalogModel: StaticCatalogFallbackModel | undefined;
const getStaticCatalogModel = () => {
if (!staticCatalogResolved) {
staticCatalogResolved = true;
staticCatalogModel = resolveBundledStaticCatalogModel({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
workspaceDir,
includeRuntimeDiscovery: true,
});
}
return staticCatalogModel;
};
const model = resolveModelWithPreparedRegistry({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
modelRegistry,
cfg,
agentDir: resolvedAgentDir,
manifestAlias: normalizedRef.manifestAlias,
workspaceDir,
authProfileId: options?.authProfileId,
authProfileMode: options?.authProfileMode,
preferredProfile: options?.preferredProfile,
runtimeHooks,
getStaticCatalogModel,
});
if (model) {
return { model, authStorage, modelRegistry };
}
return {
error: buildUnknownModelError({
provider: normalizedRef.provider,
modelId: normalizedRef.model,
cfg,
agentDir: resolvedAgentDir,
workspaceDir,
runtimeHooks,
}),
authStorage,
modelRegistry,
};
};
return preparedModelRuntime
? withPluginRuntimeGenerationScope(preparedModelRuntime, resolve)
: resolve();
}
export async function resolveModelAsync(
provider: string,
modelId: string,
@@ -1,15 +1,25 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { makeOpenClawConfigFixture } from "./embedded-agent-runner/model.test-harness.js";
const runtimeMocks = vi.hoisted(() => {
class OwnerNotPublishedError extends Error {}
const createLease = (owner: string) => {
const authStorage = { owner };
const modelRegistry = { owner };
return {
authStorage,
modelRegistry,
snapshot: {
createStores: vi.fn(() => ({ authStorage, modelRegistry })),
},
release: vi.fn(),
};
};
const requestLease = createLease("request");
const publishedLease = createLease("published");
return {
OwnerNotPublishedError,
authStorage: {},
modelRegistry: {},
preparedSnapshot: {
createStores: vi.fn(),
},
release: vi.fn(),
acquire: vi.fn(async () => requestLease),
publishedLease,
requestLease,
resolveModelAsync: vi.fn(async () => ({
model: {
id: "chat-latest",
@@ -19,31 +29,24 @@ const runtimeMocks = vi.hoisted(() => {
baseUrl: "https://api.openai.com/v1",
},
})),
staticCatalogModel: vi.fn(),
};
});
runtimeMocks.preparedSnapshot.createStores.mockReturnValue({
authStorage: runtimeMocks.authStorage,
modelRegistry: runtimeMocks.modelRegistry,
});
vi.mock("./prepared-model-runtime.js", () => ({
PreparedModelRuntimeOwnerNotPublishedError: runtimeMocks.OwnerNotPublishedError,
acquireReadOnlyPreparedModelRuntime: vi.fn(async () => ({
snapshot: runtimeMocks.preparedSnapshot,
release: runtimeMocks.release,
})),
acquireReadOnlyPreparedModelRuntime: runtimeMocks.acquire,
}));
vi.mock("./embedded-agent-runner/model.js", () => ({
resolveModel: () => {
throw new runtimeMocks.OwnerNotPublishedError("owner missing");
},
resolveModelAsync: runtimeMocks.resolveModelAsync,
}));
vi.mock("./embedded-agent-runner/model.static-catalog.js", () => ({
resolveBundledStaticCatalogModel: () => undefined,
resolveBundledStaticCatalogModel: runtimeMocks.staticCatalogModel,
}));
vi.mock("../plugins/provider-runtime.js", () => ({
normalizeProviderTransportWithPlugin: () => undefined,
}));
vi.mock("./agent-scope.js", () => ({
@@ -54,13 +57,28 @@ vi.mock("./agent-scope.js", () => ({
}));
describe("resolveEffectiveToolInventoryRuntimeModelContextAsync", () => {
it("prepares dynamic model context when no lifecycle owner exists", async () => {
beforeEach(() => {
runtimeMocks.acquire.mockReset().mockResolvedValue(runtimeMocks.requestLease);
runtimeMocks.requestLease.snapshot.createStores.mockClear();
runtimeMocks.publishedLease.snapshot.createStores.mockClear();
runtimeMocks.resolveModelAsync.mockClear();
runtimeMocks.requestLease.release.mockClear();
runtimeMocks.publishedLease.release.mockClear();
runtimeMocks.staticCatalogModel.mockReset();
});
it.each([
{ owner: "request-owned", lease: runtimeMocks.requestLease },
{ owner: "published", lease: runtimeMocks.publishedLease },
])("prepares dynamic model context with a $owner runtime lease", async ({ lease }) => {
runtimeMocks.acquire.mockResolvedValueOnce(lease);
const { resolveEffectiveToolInventoryRuntimeModelContextAsync } =
await import("./tools-effective-inventory.js");
const cfg = makeOpenClawConfigFixture();
await expect(
resolveEffectiveToolInventoryRuntimeModelContextAsync({
cfg: {},
cfg,
agentId: "main",
agentDir: "/tmp/agents/main/agent",
workspaceDir: "/tmp/workspace-main",
@@ -75,15 +93,120 @@ describe("resolveEffectiveToolInventoryRuntimeModelContextAsync", () => {
"openai",
"chat-latest",
"/tmp/agents/main/agent",
{},
cfg,
{
agentId: "main",
workspaceDir: "/tmp/workspace-main",
authStorage: runtimeMocks.authStorage,
modelRegistry: runtimeMocks.modelRegistry,
preparedModelRuntime: runtimeMocks.preparedSnapshot,
authStorage: lease.authStorage,
modelRegistry: lease.modelRegistry,
preparedModelRuntime: lease.snapshot,
},
);
expect(runtimeMocks.release).toHaveBeenCalledTimes(1);
expect(runtimeMocks.acquire).toHaveBeenCalledWith({
agentId: "main",
agentDir: "/tmp/agents/main/agent",
config: cfg,
workspaceDir: "/tmp/workspace-main",
});
expect(lease.release).toHaveBeenCalledTimes(1);
});
it.each([
{ modelProvider: "", modelId: "chat-latest" },
{ modelProvider: "openai", modelId: " " },
])("skips runtime preparation for invalid model input", async (input) => {
const { resolveEffectiveToolInventoryRuntimeModelContextAsync } =
await import("./tools-effective-inventory.js");
await expect(
resolveEffectiveToolInventoryRuntimeModelContextAsync({
cfg: {},
...input,
}),
).resolves.toEqual({});
expect(runtimeMocks.acquire).not.toHaveBeenCalled();
expect(runtimeMocks.resolveModelAsync).not.toHaveBeenCalled();
expect(runtimeMocks.requestLease.release).not.toHaveBeenCalled();
});
it("uses configured model context without acquiring a runtime lease", async () => {
const { resolveEffectiveToolInventoryRuntimeModelContextAsync } =
await import("./tools-effective-inventory.js");
const cfg = makeOpenClawConfigFixture({
models: {
providers: {
custom: {
api: "anthropic-messages",
models: [
{
id: "configured",
name: "Configured",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 1024,
},
],
},
},
},
});
await expect(
resolveEffectiveToolInventoryRuntimeModelContextAsync({
cfg,
modelProvider: "custom",
modelId: "configured",
}),
).resolves.toMatchObject({
modelApi: "anthropic-messages",
runtimeModel: { id: "configured", provider: "custom" },
});
expect(runtimeMocks.acquire).not.toHaveBeenCalled();
expect(runtimeMocks.resolveModelAsync).not.toHaveBeenCalled();
expect(runtimeMocks.requestLease.release).not.toHaveBeenCalled();
});
it("uses bundled model context without acquiring a runtime lease", async () => {
runtimeMocks.staticCatalogModel.mockReturnValue({
id: "bundled",
name: "Bundled",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
});
const { resolveEffectiveToolInventoryRuntimeModelContextAsync } =
await import("./tools-effective-inventory.js");
await expect(
resolveEffectiveToolInventoryRuntimeModelContextAsync({
cfg: {},
modelProvider: "openai",
modelId: "bundled",
}),
).resolves.toMatchObject({
modelApi: "openai-responses",
runtimeModel: { id: "bundled", provider: "openai" },
});
expect(runtimeMocks.acquire).not.toHaveBeenCalled();
expect(runtimeMocks.resolveModelAsync).not.toHaveBeenCalled();
expect(runtimeMocks.requestLease.release).not.toHaveBeenCalled();
});
it("releases the runtime lease when dynamic model resolution fails", async () => {
const failure = new Error("dynamic model failed");
runtimeMocks.resolveModelAsync.mockRejectedValueOnce(failure);
const { resolveEffectiveToolInventoryRuntimeModelContextAsync } =
await import("./tools-effective-inventory.js");
await expect(
resolveEffectiveToolInventoryRuntimeModelContextAsync({
cfg: {},
modelProvider: "openai",
modelId: "chat-latest",
}),
).rejects.toBe(failure);
expect(runtimeMocks.requestLease.release).toHaveBeenCalledTimes(1);
});
});
+12 -29
View File
@@ -3,6 +3,7 @@
* Verifies grouped tool sources, plugin registry inputs, and session-context filters.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import type { createOpenClawCodingTools } from "./agent-tools.js";
@@ -39,7 +40,6 @@ const effectiveInventoryState = vi.hoisted(() => ({
effectivePolicy: {} as { profile?: string; providerProfile?: string },
normalizeToolsMock: vi.fn((options: { tools: AnyAgentTool[] }) => options.tools),
staticCatalogModelMock: vi.fn((_options: unknown) => undefined as unknown),
dynamicModelMock: vi.fn((_options: unknown) => undefined as unknown),
normalizeTransportMock: vi.fn((_options: unknown) => undefined as unknown),
createToolsMock: vi.fn<typeof createOpenClawCodingTools>(
(_options) =>
@@ -91,21 +91,7 @@ vi.mock("./embedded-agent-runner/model.static-catalog.js", () => ({
}));
vi.mock("./embedded-agent-runner/model.js", () => ({
resolveModel: (
provider: unknown,
modelId: unknown,
agentDir: unknown,
cfg: unknown,
options: unknown,
) => ({
model: effectiveInventoryState.dynamicModelMock({
provider,
modelId,
agentDir,
cfg,
options,
}),
}),
resolveModelAsync: vi.fn(),
}));
vi.mock("../plugins/provider-runtime.js", () => ({
@@ -133,7 +119,6 @@ async function loadHarness(options?: {
effectiveInventoryState.normalizeToolsMock =
options?.normalizeToolsMock ?? vi.fn((normalizeOptions) => normalizeOptions.tools);
effectiveInventoryState.staticCatalogModelMock = vi.fn((_options: unknown) => undefined);
effectiveInventoryState.dynamicModelMock = vi.fn((_options: unknown) => undefined);
effectiveInventoryState.normalizeTransportMock = vi.fn((_options: unknown) => undefined);
effectiveInventoryState.createToolsMock =
options?.createToolsMock ??
@@ -159,7 +144,6 @@ describe("resolveEffectiveToolInventory", () => {
effectiveInventoryState.effectivePolicy = {};
effectiveInventoryState.normalizeToolsMock = vi.fn((options) => options.tools);
effectiveInventoryState.staticCatalogModelMock = vi.fn((_options: unknown) => undefined);
effectiveInventoryState.dynamicModelMock = vi.fn((_options: unknown) => undefined);
effectiveInventoryState.normalizeTransportMock = vi.fn((_options: unknown) => undefined);
effectiveInventoryState.createToolsMock = vi.fn<typeof createOpenClawCodingTools>(
(_options) => effectiveInventoryState.tools,
@@ -778,7 +762,7 @@ describe("resolveEffectiveToolInventory", () => {
);
});
it("uses dynamic provider model context before quarantining runtime-normalized tools", async () => {
it("uses prepared model context before quarantining runtime-normalized tools", async () => {
const normalizeToolsMock = vi.fn((options: { tools: AnyAgentTool[]; modelApi?: string }) =>
options.tools.map((entry) =>
entry.name === "parameter_free" && options.modelApi === "openai-responses"
@@ -808,18 +792,25 @@ describe("resolveEffectiveToolInventory", () => {
normalizeToolsMock,
},
);
effectiveInventoryState.dynamicModelMock.mockReturnValue({
const runtimeModel: ProviderRuntimeModel = {
id: "chat-latest",
name: "chat-latest",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
});
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 1024,
};
const result = resolveEffectiveToolInventoryInner({
cfg: {},
modelProvider: "openai",
modelId: "chat-latest",
modelApi: runtimeModel.api,
runtimeModel,
});
expect(result.groups[0]?.tools[0]).toMatchObject({
@@ -828,14 +819,6 @@ describe("resolveEffectiveToolInventory", () => {
pluginId: "normalized-plugin",
});
expect(result.notices).toBeUndefined();
expect(effectiveInventoryState.dynamicModelMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
modelId: "chat-latest",
agentDir: "/tmp/agents/main/agent",
options: expect.objectContaining({ workspaceDir: "/tmp/workspace-main" }),
}),
);
expect(normalizeToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
+11 -45
View File
@@ -18,13 +18,10 @@ import { normalizeProviderTransportWithPlugin } from "../plugins/provider-runtim
import { resolveAgentDir, resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js";
import { createOpenClawCodingTools } from "./agent-tools.js";
import { resolveEffectiveToolPolicy } from "./agent-tools.policy.js";
import { resolveModel, resolveModelAsync } from "./embedded-agent-runner/model.js";
import { resolveModelAsync } from "./embedded-agent-runner/model.js";
import { resolveBundledStaticCatalogModel } from "./embedded-agent-runner/model.static-catalog.js";
import { normalizeStaticProviderModelId } from "./model-ref-shared.js";
import {
PreparedModelRuntimeOwnerNotPublishedError,
acquireReadOnlyPreparedModelRuntime,
} from "./prepared-model-runtime.js";
import { acquireReadOnlyPreparedModelRuntime } from "./prepared-model-runtime.js";
import { normalizeToolPolicyName } from "./tool-policy.js";
import { buildRuntimeCompatibleToolInventory } from "./tools-effective-inventory-build.js";
import { buildEffectiveToolInventoryGroups } from "./tools-effective-inventory-groups.js";
@@ -156,29 +153,8 @@ function resolveConfiguredFallbackApi(
: "openai-responses";
}
function resolveDynamicRuntimeModelContext(params: {
cfg: OpenClawConfig;
agentId?: string;
agentDir?: string;
workspaceDir?: string;
provider: string;
modelId: string;
}): { modelApi?: string; runtimeModel?: ProviderRuntimeModel } {
const runtimeModel = resolveModel(params.provider, params.modelId, params.agentDir, params.cfg, {
agentId: params.agentId,
workspaceDir: params.workspaceDir,
}).model as ProviderRuntimeModel | undefined;
if (!runtimeModel) {
return {};
}
return {
modelApi: runtimeModel.api,
runtimeModel,
};
}
/** Resolves the runtime model metadata needed to filter model-compatible tools. */
function resolveEffectiveToolInventoryRuntimeModelContext(params: {
/** Resolves configured or bundled metadata without starting provider discovery. */
function resolveStaticToolInventoryRuntimeModelContext(params: {
cfg: OpenClawConfig;
agentId?: string;
agentDir?: string;
@@ -241,14 +217,7 @@ function resolveEffectiveToolInventoryRuntimeModelContext(params: {
};
}
if (!bundledStaticModel) {
return resolveDynamicRuntimeModelContext({
cfg: params.cfg,
agentId,
agentDir: params.agentDir,
workspaceDir,
provider,
modelId,
});
return {};
}
const runtimeModel = applyProviderTransportNormalization({
cfg: params.cfg,
@@ -268,14 +237,11 @@ function resolveEffectiveToolInventoryRuntimeModelContext(params: {
/** Resolves dynamic model metadata after publishing a request-owned read snapshot when needed. */
export async function resolveEffectiveToolInventoryRuntimeModelContextAsync(
params: Parameters<typeof resolveEffectiveToolInventoryRuntimeModelContext>[0],
): Promise<ReturnType<typeof resolveEffectiveToolInventoryRuntimeModelContext>> {
try {
return resolveEffectiveToolInventoryRuntimeModelContext(params);
} catch (error) {
if (!(error instanceof PreparedModelRuntimeOwnerNotPublishedError)) {
throw error;
}
params: Parameters<typeof resolveStaticToolInventoryRuntimeModelContext>[0],
): Promise<ReturnType<typeof resolveStaticToolInventoryRuntimeModelContext>> {
const staticContext = resolveStaticToolInventoryRuntimeModelContext(params);
if (staticContext.runtimeModel) {
return staticContext;
}
const provider = normalizeProviderId(params.modelProvider ?? "");
@@ -352,7 +318,7 @@ export function resolveEffectiveToolInventory(
modelApi: params.modelApi ?? params.runtimeModel?.api,
runtimeModel: params.runtimeModel,
}
: resolveEffectiveToolInventoryRuntimeModelContext({
: resolveStaticToolInventoryRuntimeModelContext({
cfg: params.cfg,
agentId,
agentDir,