diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 275bcd6de7ed..c581512cb667 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -4,6 +4,10 @@ import type { Model } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTempDirTracker } from "../../../test/helpers/temp-dir.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, +} from "../../config/runtime-snapshot.js"; import { replaceSessionEntry } from "../../config/sessions/session-accessor.js"; import type { TranscriptEntryAnchor } from "../../config/sessions/transcript-entry-anchor.js"; import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../context-engine/host-compat.js"; @@ -226,6 +230,7 @@ beforeEach(async () => { }); afterEach(() => { + clearRuntimeConfigSnapshot(); closeOpenClawAgentDatabasesForTest(); closeOpenClawStateDatabaseForTest(); trajectoryTempDirs.cleanup(); @@ -2036,6 +2041,45 @@ describe("selectAgentHarness", () => { }); }); + it("ignores catalog-seeded compatibility when selecting an official OpenAI route", () => { + const createConfig = (compat?: { supportsStore: boolean }) => + ({ + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 8192, + ...(compat ? { compat } : {}), + }, + ], + }, + }, + }, + }) satisfies OpenClawConfig; + const sourceConfig = createConfig(); + const runtimeConfig = createConfig({ supportsStore: false }); + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + + expect( + buildAgentHarnessSupportContext({ + provider: "openai", + modelId: "gpt-5.5", + requestedRuntime: "codex", + config: runtimeConfig, + }).modelProvider, + ).toMatchObject({ + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }); + }); + it.each([ { label: "default", diff --git a/src/agents/harness/support.ts b/src/agents/harness/support.ts index c33927ac9cbe..3b97179595cc 100644 --- a/src/agents/harness/support.ts +++ b/src/agents/harness/support.ts @@ -5,6 +5,7 @@ import { resolveMergedModelProviderModels, resolveModelProviderRouteOverridePresence, } from "../../config/model-provider-config.js"; +import { projectConfigOntoRuntimeSourceSnapshot } from "../../config/runtime-source-projection.js"; import type { ModelApi } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { @@ -86,6 +87,9 @@ export function buildAgentHarnessSupportContext(params: { providerOwnership?: HarnessProviderOwnership; }): AgentHarnessSupportContext { const providerConfig = resolveMergedModelProviderConfig(params.config, params.provider); + const authoredConfig = params.config + ? projectConfigOntoRuntimeSourceSnapshot(params.config) + : undefined; const modelId = params.modelId ? normalizeModelId(params.provider, params.modelId) : undefined; const modelConfig = modelId ? resolveMergedModelProviderModels({ @@ -119,7 +123,7 @@ export function buildAgentHarnessSupportContext(params: { requestTransportOverrides: resolveModelProviderRouteOverridePresence({ provider: params.provider, modelId: params.modelId, - config: params.config, + authoredConfig, canonicalizeModelId: (configuredModelId) => canonicalizeProviderModelId(params.provider, configuredModelId), }), diff --git a/src/config/defaults.test.ts b/src/config/defaults.test.ts index 3f493a907883..351f326bdb27 100644 --- a/src/config/defaults.test.ts +++ b/src/config/defaults.test.ts @@ -1,6 +1,8 @@ // Verifies default config values and environment-sensitive overrides. import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProviderResolveModelRoutesContext } from "../plugin-sdk/provider-model-types.js"; +import { resolveProviderModelRoutes } from "../plugins/provider-model-routes.js"; import { DEFAULT_SUBAGENT_ARCHIVE_AFTER_MINUTES, DEFAULT_SUBAGENT_MAX_CONCURRENT, @@ -11,6 +13,8 @@ import { applyContextPruningDefaults, applyMessageDefaults, } from "./defaults.js"; +import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "./runtime-snapshot.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; const mocks = vi.hoisted(() => ({ applyProviderConfigDefaultsForConfig: vi.fn(), @@ -32,6 +36,7 @@ describe("config defaults", () => { }); afterEach(() => { + clearRuntimeConfigSnapshot(); vi.unstubAllEnvs(); }); @@ -158,6 +163,7 @@ describe("applyModelDefaults catalog seeding", () => { maxTokens: 128_000, cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, thinkingLevelMap: { off: "none" }, + compat: { supportsStore: false }, }, ], }, @@ -243,6 +249,46 @@ describe("applyModelDefaults catalog seeding", () => { expect(model.maxTokens).toBe(4_096); }); + it("keeps catalog-seeded compatibility out of authored route overrides", async () => { + const { applyModelDefaults } = await import("./defaults.js"); + const sourceConfig: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [ + { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 8192, + }, + ], + }, + }, + }, + }; + const runtimeConfig = applyModelDefaults(sourceConfig, { manifestRegistry: catalogRegistry }); + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + const resolveModelRoutes = vi.fn((_context: ProviderResolveModelRoutesContext) => ({ + kind: "indeterminate" as const, + })); + + resolveProviderModelRoutes({ + provider: "openai", + modelId: "gpt-5.6-sol", + config: runtimeConfig, + env: {}, + surface: { resolveModelRoutes }, + }); + + expect(resolveModelRoutes.mock.calls[0]?.[0]).toMatchObject({ + requestTransportOverrides: "none", + }); + }); + it("preserves catalog tiered pricing when flat cost fields are authored", async () => { const { applyModelDefaults } = await import("./defaults.js"); const tieredRegistry = { diff --git a/src/config/model-provider-config.test.ts b/src/config/model-provider-config.test.ts index 62598641050c..f1356af7d0b3 100644 --- a/src/config/model-provider-config.test.ts +++ b/src/config/model-provider-config.test.ts @@ -68,14 +68,14 @@ describe("resolveModelProviderRouteOverridePresence", () => { resolveModelProviderRouteOverridePresence({ provider: "openai", modelId: "gpt-5.5", - config, + authoredConfig: config, }), ).toBe("present"); expect( resolveModelProviderRouteOverridePresence({ provider: "openai", modelId: "gpt-5.5-empty", - config, + authoredConfig: config, }), ).toBe("none"); }); @@ -85,7 +85,7 @@ describe("resolveModelProviderRouteOverridePresence", () => { resolveModelProviderRouteOverridePresence({ provider: "openai", modelId: "gpt-5.5", - config: { + authoredConfig: { models: { providers: { openai: { baseUrl: "", timeoutSeconds: 90, models: [model("gpt-5.5")] }, diff --git a/src/config/model-provider-config.ts b/src/config/model-provider-config.ts index 0a3af776c075..868c96de9cfb 100644 --- a/src/config/model-provider-config.ts +++ b/src/config/model-provider-config.ts @@ -46,10 +46,10 @@ function hasNonEmptyRecord(value: unknown): boolean { export function resolveModelProviderRouteOverridePresence(params: { provider: string; modelId?: string; - config?: OpenClawConfig; + authoredConfig?: OpenClawConfig; canonicalizeModelId?: (modelId: string) => string; }): ProviderRouteOverridePresence { - const providerConfig = resolveMergedModelProviderConfig(params.config, params.provider); + const providerConfig = resolveMergedModelProviderConfig(params.authoredConfig, params.provider); if (!providerConfig) { return "none"; } diff --git a/src/plugins/provider-model-routes.ts b/src/plugins/provider-model-routes.ts index dfde2186f6c2..87588de0664e 100644 --- a/src/plugins/provider-model-routes.ts +++ b/src/plugins/provider-model-routes.ts @@ -5,6 +5,7 @@ import { resolveMergedModelProviderModels, resolveModelProviderRouteOverridePresence, } from "../config/model-provider-config.js"; +import { projectConfigOntoRuntimeSourceSnapshot } from "../config/runtime-source-projection.js"; import type { ModelApi, ModelDefinitionConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { @@ -84,6 +85,11 @@ export function createProviderModelRoutesResolver(params: { : params.surface; const resolveModelRoutes = surface?.resolveModelRoutes; const providerConfig = resolveMergedModelProviderConfig(params.config, provider); + // Runtime defaults copy catalog capabilities into configured model rows. Route + // eligibility must read the authored view or metadata looks like request behavior. + const authoredConfig = params.config + ? projectConfigOntoRuntimeSourceSnapshot(params.config) + : undefined; const configuredProvider = providerConfig ? { api: providerConfig.api, baseUrl: providerConfig.baseUrl } : undefined; @@ -105,7 +111,7 @@ export function createProviderModelRoutesResolver(params: { ? "present" : resolveModelProviderRouteOverridePresence({ provider, - config: params.config, + authoredConfig, }); const routeOverridePresenceByModel = new Map( [...configuredModels.keys()].map( @@ -117,7 +123,7 @@ export function createProviderModelRoutesResolver(params: { : resolveModelProviderRouteOverridePresence({ provider, modelId, - config: params.config, + authoredConfig, canonicalizeModelId, }), ] as const,