From 5808b72ed61451beb0f9a83b38b811a2e869f8bc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 02:14:29 -0700 Subject: [PATCH] refactor(models): own compat in provider catalogs (#112542) --- docs/.generated/config-baseline.counts.json | 2 +- docs/.generated/config-baseline.sha256 | 4 +- docs/concepts/model-providers.md | 2 + docs/gateway/config-tools.md | 37 ++- docs/gateway/local-models.md | 2 + docs/providers/baseten.md | 16 -- docs/providers/moonshot.md | 4 - docs/providers/stepfun.md | 40 ---- extensions/venice/index.test.ts | 1 - extensions/venice/index.ts | 1 - extensions/xai/index.test.ts | 2 - extensions/xai/model-compat.ts | 1 - .../src/model-catalog-normalize.ts | 2 - .../src/model-catalog-types.ts | 2 - ...tools.create-openclaw-coding-tools.test.ts | 1 - ...ent-tools.model-provider-collision.test.ts | 2 - .../embedded-agent-runner/model.test.ts | 37 ++- src/agents/embedded-agent-runner/model.ts | 44 +++- src/agents/model-catalog.test.ts | 56 +++++ src/agents/model-catalog.ts | 54 ++++- src/agents/model-compat-catalog.test.ts | 66 ++++++ src/agents/model-compat-catalog.ts | 72 ++++++ src/agents/model-selection-shared.ts | 11 +- src/agents/model-selection.test.ts | 3 +- src/agents/models-config.merge.test.ts | 39 +++ src/agents/models-config.merge.ts | 15 ++ src/agents/tools-effective-inventory.test.ts | 7 +- src/auto-reply/reply/model-selection.test.ts | 7 +- .../doctor/shared/deprecation-compat.ts | 16 ++ ...y-config-migrations.runtime.models.test.ts | 77 ++++++ ...legacy-config-migrations.runtime.models.ts | 222 ++++++++++++++++++ src/config/config-misc.test.ts | 1 - src/config/dead-config-keys.test.ts | 2 + src/config/types.models.ts | 4 - src/config/zod-schema.core.ts | 2 - src/plugin-sdk/provider-model-shared.ts | 1 - .../test-helpers/provider-runtime-contract.ts | 1 - src/plugins/provider-model-compat.ts | 6 - 38 files changed, 736 insertions(+), 126 deletions(-) create mode 100644 src/agents/model-compat-catalog.test.ts create mode 100644 src/agents/model-compat-catalog.ts diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index e0594a8e01f1..3f7324dbbae8 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2305, + "core": 2303, "channel": 3627, "plugin": 3556 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index c833963bb6e2..113da374c251 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -44130ea5925c44f8817fb74fb502c271b45fa343026fd0e98e0fd686497e868a config-baseline.json -388219300c6e874aee82b33706a00486ed334b21da27f4b89ebb4697af890331 config-baseline.core.json +c98d7150aae3d5b6f9b50a1bdbc479bc1b2e20f8713d0b32ceef1f32ee9fb185 config-baseline.json +663339158a0bace1ebda4d5f84a74c7d8b40b3a911c1f3fcbb16f730c9af37bd config-baseline.core.json d8a79905c6191dfb9391c16afd33cf9ac573691d26b3b8cc635e19fd7f2ae316 config-baseline.channel.json 28460228b14a94a2b93040ab3f43b214bdc6d4fda4139a75b21e70fc5983dd56 config-baseline.plugin.json diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 297e500456f4..998490b4242e 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -357,6 +357,8 @@ Use `models.providers` (or `models.json`) to add **custom** providers or OpenAI/ Many of the bundled provider plugins below already publish a default catalog. Use explicit `models.providers.` entries only when you want to override the default base URL, headers, or model list. +Bundled and catalog-known routes take their `compat` capabilities from the owning provider plugin. A config `compat` block is for a custom provider/model or a different `api`/`baseUrl` route whose endpoint contract you have verified; see the [custom-provider capability guide](/gateway/config-tools#custom-provider-capability-declarations). Doctor removes legacy values that merely repeat the catalog and leaves divergent values visible for operator review. + Gateway model capability checks also read explicit `models.providers..models[]` metadata. If a custom or proxy model accepts images, set `input: ["text", "image"]` on that model so WebChat and node-origin attachment paths pass images as native model inputs instead of text-only media refs. `agents.defaults.models["provider/model"]` controls aliases and per-model metadata for agents. It neither restricts overrides nor registers a new runtime model by itself. For custom provider models, also add `models.providers..models[]` with at least the matching `id`; use `agents.defaults.modelPolicy.allow` separately when you want an override restriction. diff --git a/docs/gateway/config-tools.md b/docs/gateway/config-tools.md index 2f23c6d1c9ba..ee5dbf3bec5e 100644 --- a/docs/gateway/config-tools.md +++ b/docs/gateway/config-tools.md @@ -542,11 +542,38 @@ Configuring a custom/local provider `baseUrl` is also the narrow network trust d - `models.providers.*.models.*.input`: model input modalities. Use `["text"]` for text-only models and `["text", "image"]` for native image/vision models. Image attachments are only injected into agent turns when the selected model is marked image-capable. - `models.providers.*.models.*.contextWindow`: native model context window metadata. This overrides provider-level `contextWindow` for that model. - `models.providers.*.models.*.contextTokens`: optional runtime context cap. This overrides provider-level `contextTokens`; use it when you want a smaller effective context budget than the model's native `contextWindow`; `openclaw models list` shows both values when they differ. - - `models.providers.*.models.*.compat.supportsDeveloperRole`: optional compatibility hint. For `api: "openai-completions"` with a non-empty non-native `baseUrl` (host not `api.openai.com`), OpenClaw forces this to `false` at runtime. Empty/omitted `baseUrl` keeps default OpenAI behavior. - - `models.providers.*.models.*.compat.requiresStringContent`: optional compatibility hint for string-only OpenAI-compatible chat endpoints. When `true`, OpenClaw flattens pure text `messages[].content` arrays into plain strings before sending the request. - - `models.providers.*.models.*.compat.strictMessageKeys`: optional compatibility hint for strict OpenAI-compatible chat endpoints. When `true`, OpenClaw strips outgoing Chat Completions message objects to `role` and `content` before sending the request. - - `models.providers.*.models.*.compat.thinkingFormat`: optional thinking payload hint. Use `"together"` for Together-style `reasoning.enabled`, `"qwen"` for top-level `enable_thinking`, or `"qwen-chat-template"` for `chat_template_kwargs.enable_thinking` on Qwen-family OpenAI-compatible servers that support request-level chat-template kwargs, such as vLLM. Configured vLLM Qwen models expose binary `/think` choices (`off`, `on`) for these formats. - - `models.providers.*.models.*.compat.requiresReasoningContentOnAssistantMessages`: optional compatibility hint for DeepSeek-style Chat Completions backends that require prior assistant messages to keep `reasoning_content` on replay. When `true`, OpenClaw preserves that field on outgoing assistant messages. Use this when wiring a custom DeepSeek-compatible proxy that rejects requests after stripped reasoning. Default `false`. + + #### Custom provider capability declarations + + Provider catalogs own `compat` for bundled and catalog-known model routes. Do not copy those flags into config: OpenClaw uses the catalog row when the configured `api` and `baseUrl` still identify that route. `openclaw doctor --fix` removes matching legacy overrides and reports divergent values for review. + + A `compat` block remains supported for a genuinely custom provider, custom model, or catalog model routed to a different endpoint. Set only capabilities verified against that endpoint: + + | Custom-route key | Runtime contract | + | --- | --- | + | `supportsStore` | Accepts the OpenAI `store` request field. | + | `supportsPromptCacheKey` | Accepts OpenAI prompt-cache/session-affinity keys. | + | `supportsDeveloperRole` | Accepts `developer` messages instead of requiring `system`. | + | `supportsReasoningEffort` | Accepts a reasoning-effort control. | + | `supportsTemperature` | Accepts `temperature` for this model and adapter. | + | `supportsUsageInStreaming` | Emits usage metadata in streaming responses. | + | `supportsTools` | Supports structured tool/function calling. Set `false` to disable tools. | + | `supportsStrictMode` | Accepts strict tool schemas. | + | `requiresStringContent` | Requires plain-string Chat Completions message content. | + | `strictMessageKeys` | Requires outgoing messages to contain only accepted keys. | + | `visibleReasoningDetailTypes` | Names reasoning detail block types safe to show in transcripts. | + | `supportedReasoningEfforts` | Lists the endpoint's accepted reasoning labels. | + | `reasoningEffortMap` | Maps OpenClaw thinking labels to endpoint-specific labels. | + | `maxTokensField` | Selects `max_tokens` or `max_completion_tokens`. | + | `thinkingFormat` | Selects the endpoint's reasoning payload dialect. | + | `requiresToolResultName` | Requires a tool name on tool-result messages. | + | `requiresAssistantAfterToolResult` | Requires an assistant message after tool results. | + | `requiresThinkingAsText` | Replays reasoning as text rather than structured content. | + | `requiresReasoningContentOnAssistantMessages` | Preserves DeepSeek-style `reasoning_content` during replay. | + | `toolSchemaProfile` | Selects a provider-defined tool-schema normalization profile. | + | `unsupportedToolSchemaKeywords` | Removes named JSON Schema keywords rejected by the endpoint. | + | `toolCallArgumentsEncoding` | Selects the endpoint's tool-call argument encoding. | + | `requiresOpenAiAnthropicToolPayload` | Converts OpenAI-shaped tool calls to Anthropic-family payloads. | diff --git a/docs/gateway/local-models.md b/docs/gateway/local-models.md index d6c0b7a7792b..e5885c6e7edf 100644 --- a/docs/gateway/local-models.md +++ b/docs/gateway/local-models.md @@ -184,6 +184,8 @@ Behavior notes for local/proxied `/v1` backends: - Native-OpenAI-only request shaping does not apply: no `service_tier`, no Responses `store`, no OpenAI reasoning-compat payload shaping, no prompt-cache hints. - Hidden OpenClaw attribution headers (`originator`, `version`, `User-Agent`) are not injected on custom proxy URLs. +Compat declarations are only for the custom endpoint described by this provider row. Catalog-known routes use provider-owned capabilities instead; see the [custom-provider capability guide](/gateway/config-tools#custom-provider-capability-declarations). + Compat overrides for stricter OpenAI-compatible backends: - **String-only content**: some servers accept only string `messages[].content`, not structured content-part arrays. Set `models.providers..models[].compat.requiresStringContent: true`. diff --git a/docs/providers/baseten.md b/docs/providers/baseten.md index 03ec26d2b071..74fd10e5b03e 100644 --- a/docs/providers/baseten.md +++ b/docs/providers/baseten.md @@ -130,22 +130,6 @@ Most setups only need the API key. To pin the provider explicitly: input: ["text", "image"], contextWindow: 1048000, maxTokens: 32000, - compat: { - supportsStore: false, - supportsDeveloperRole: false, - supportsUsageInStreaming: true, - supportsStrictMode: true, - supportsTools: true, - supportsReasoningEffort: true, - supportedReasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh"], - reasoningEffortMap: { - off: "none", - none: "none", - adaptive: "xhigh", - max: "xhigh", - }, - maxTokensField: "max_tokens", - }, }, ], }, diff --git a/docs/providers/moonshot.md b/docs/providers/moonshot.md index cfd19c4be30c..da3ace62cfc6 100644 --- a/docs/providers/moonshot.md +++ b/docs/providers/moonshot.md @@ -166,10 +166,6 @@ onboarding. cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, contextWindow: 1048576, maxTokens: 1048576, - compat: { - supportsReasoningEffort: true, - supportedReasoningEfforts: ["max"], - }, }, { id: "kimi-k2.7-code", diff --git a/docs/providers/stepfun.md b/docs/providers/stepfun.md index 2e341f074637..39861cb47fd4 100644 --- a/docs/providers/stepfun.md +++ b/docs/providers/stepfun.md @@ -157,26 +157,6 @@ A single auth flow writes region-matched profiles for both `stepfun` and `stepfu cost: { input: 0.2, output: 1.15, cacheRead: 0.04, cacheWrite: 0 }, contextWindow: 262144, maxTokens: 262144, - compat: { - supportsStore: false, - supportsDeveloperRole: false, - supportsUsageInStreaming: false, - supportsReasoningEffort: true, - supportsStrictMode: false, - supportedReasoningEfforts: ["low", "medium", "high"], - maxTokensField: "max_tokens", - reasoningEffortMap: { - off: "low", - none: "low", - minimal: "low", - low: "low", - medium: "medium", - high: "high", - xhigh: "high", - adaptive: "high", - max: "high", - }, - }, }, { id: "step-3.5-flash", @@ -217,26 +197,6 @@ A single auth flow writes region-matched profiles for both `stepfun` and `stepfu cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 262144, maxTokens: 262144, - compat: { - supportsStore: false, - supportsDeveloperRole: false, - supportsUsageInStreaming: false, - supportsReasoningEffort: true, - supportsStrictMode: false, - supportedReasoningEfforts: ["low", "medium", "high"], - maxTokensField: "max_tokens", - reasoningEffortMap: { - off: "low", - none: "low", - minimal: "low", - low: "low", - medium: "medium", - high: "high", - xhigh: "high", - adaptive: "high", - max: "high", - }, - }, }, { id: "step-3.5-flash", diff --git a/extensions/venice/index.test.ts b/extensions/venice/index.test.ts index fb9a8bead38f..5001310e8229 100644 --- a/extensions/venice/index.test.ts +++ b/extensions/venice/index.test.ts @@ -39,7 +39,6 @@ describe("venice provider plugin", () => { "minContains", "maxContains", ], - nativeWebSearchTool: true, toolCallArgumentsEncoding: "html-entities", }, }); diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index c59cdfc7eb17..b0df635e9b37 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -24,7 +24,6 @@ function applyXaiModelCompat(model: T): T { return applyModelCompatPatch(model as T & { compat?: ModelCompatConfig }, { toolSchemaProfile: "xai", unsupportedToolSchemaKeywords: [...XAI_UNSUPPORTED_SCHEMA_KEYWORDS], - nativeWebSearchTool: true, toolCallArgumentsEncoding: "html-entities", }) as T; } diff --git a/extensions/xai/index.test.ts b/extensions/xai/index.test.ts index cd9171ea7103..a3e970f4045f 100644 --- a/extensions/xai/index.test.ts +++ b/extensions/xai/index.test.ts @@ -793,13 +793,11 @@ describe("xai provider plugin", () => { const normalizedCompat = normalized?.compat as | { toolSchemaProfile?: string; - nativeWebSearchTool?: boolean; toolCallArgumentsEncoding?: string; unsupportedToolSchemaKeywords?: string[]; } | undefined; expect(normalizedCompat?.toolSchemaProfile).toBe("xai"); - expect(normalizedCompat?.nativeWebSearchTool).toBe(true); expect(normalizedCompat?.toolCallArgumentsEncoding).toBe("html-entities"); expect(normalizedCompat?.unsupportedToolSchemaKeywords).toEqual(["minContains", "maxContains"]); }); diff --git a/extensions/xai/model-compat.ts b/extensions/xai/model-compat.ts index 05e1262539a9..2838f6089e51 100644 --- a/extensions/xai/model-compat.ts +++ b/extensions/xai/model-compat.ts @@ -17,7 +17,6 @@ function resolveXaiModelCompatPatch(): ModelCompatConfig { return { toolSchemaProfile: XAI_TOOL_SCHEMA_PROFILE, unsupportedToolSchemaKeywords: Array.from(XAI_UNSUPPORTED_SCHEMA_KEYWORDS), - nativeWebSearchTool: true, toolCallArgumentsEncoding: HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING, }; } diff --git a/packages/model-catalog-core/src/model-catalog-normalize.ts b/packages/model-catalog-core/src/model-catalog-normalize.ts index 9e80e91c9650..c444fe34a4b1 100644 --- a/packages/model-catalog-core/src/model-catalog-normalize.ts +++ b/packages/model-catalog-core/src/model-catalog-normalize.ts @@ -390,8 +390,6 @@ function normalizeModelCatalogCompat(value: unknown): ModelCatalogCompatConfig | "sendSessionIdHeader", "supportsEagerToolInputStreaming", "supportsLongCacheRetention", - "nativeWebSearchTool", - "requiresMistralToolIds", "requiresOpenAiAnthropicToolPayload", ] as const; for (const field of booleanFields) { diff --git a/packages/model-catalog-core/src/model-catalog-types.ts b/packages/model-catalog-core/src/model-catalog-types.ts index f7c75f4bb7cb..a737b18e3534 100644 --- a/packages/model-catalog-core/src/model-catalog-types.ts +++ b/packages/model-catalog-core/src/model-catalog-types.ts @@ -64,9 +64,7 @@ export type ModelCatalogCompatConfig = { strictMessageKeys?: boolean; toolSchemaProfile?: string; unsupportedToolSchemaKeywords?: string[]; - nativeWebSearchTool?: boolean; toolCallArgumentsEncoding?: string; - requiresMistralToolIds?: boolean; requiresOpenAiAnthropicToolPayload?: boolean; thinkingFormat?: ModelCatalogThinkingFormat; supportedReasoningEfforts?: string[]; diff --git a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts index a5eaa2e7ac99..c828465c17fa 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -1793,7 +1793,6 @@ describe("createOpenClawCodingTools", () => { modelCompat: { toolSchemaProfile: "xai", unsupportedToolSchemaKeywords: Array.from(XAI_UNSUPPORTED_SCHEMA_KEYWORDS), - nativeWebSearchTool: true, toolCallArgumentsEncoding: "html-entities", }, }); diff --git a/src/agents/agent-tools.model-provider-collision.test.ts b/src/agents/agent-tools.model-provider-collision.test.ts index 39e0ec0b8ca6..dfe8f230cee3 100644 --- a/src/agents/agent-tools.model-provider-collision.test.ts +++ b/src/agents/agent-tools.model-provider-collision.test.ts @@ -62,7 +62,6 @@ describe("applyModelProviderToolPolicy", () => { const filtered = testing.applyModelProviderToolPolicy(baseTools, { modelCompat: { toolSchemaProfile: XAI_TOOL_SCHEMA_PROFILE, - nativeWebSearchTool: true, toolCallArgumentsEncoding: HTML_ENTITY_TOOL_CALL_ARGUMENTS_ENCODING, }, }); @@ -74,7 +73,6 @@ describe("applyModelProviderToolPolicy", () => { const filtered = testing.applyModelProviderToolPolicy(baseTools, { modelCompat: { toolSchemaProfile: XAI_TOOL_SCHEMA_PROFILE, - nativeWebSearchTool: true, }, }); diff --git a/src/agents/embedded-agent-runner/model.test.ts b/src/agents/embedded-agent-runner/model.test.ts index 690754f25cd5..03b9c0ab5f3f 100644 --- a/src/agents/embedded-agent-runner/model.test.ts +++ b/src/agents/embedded-agent-runner/model.test.ts @@ -1695,7 +1695,7 @@ describe("resolveModel", () => { expect(model.compat).toEqual( expect.objectContaining({ supportsUsageInStreaming: true, - supportsReasoningEffort: false, + supportsReasoningEffort: true, maxTokensField: "max_tokens", }), ); @@ -2931,6 +2931,41 @@ describe("resolveModel", () => { ); }); + it("does not derive reasoning from ignored compat on a catalog-owned vLLM route", () => { + resolveBundledStaticCatalogModelMock.mockReturnValueOnce({ + ...makeModel("Qwen/Qwen3-8B"), + provider: "vllm", + api: "openai-completions", + baseUrl: "http://localhost:9000", + reasoning: false, + compat: { supportsStrictMode: false }, + }); + const cfg = { + models: { + providers: { + vllm: { + baseUrl: "http://localhost:9000", + api: "openai-completions", + models: [ + { + id: "Qwen/Qwen3-8B", + name: "Qwen/Qwen3-8B", + compat: { thinkingFormat: "qwen-chat-template" }, + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + + const result = resolveModelForTest("vllm", "Qwen/Qwen3-8B", "/tmp/agent", cfg); + + expect(result.error).toBeUndefined(); + expect(result.model?.reasoning).toBe(false); + expect(result.model?.compat).toEqual(expect.objectContaining({ supportsStrictMode: false })); + expect(result.model?.compat).not.toHaveProperty("thinkingFormat"); + }); + it("infers reasoning for matching vLLM Qwen compat fallback models", () => { const cfg = { models: { diff --git a/src/agents/embedded-agent-runner/model.ts b/src/agents/embedded-agent-runner/model.ts index 87e2a90f58d1..728701adb80a 100644 --- a/src/agents/embedded-agent-runner/model.ts +++ b/src/agents/embedded-agent-runner/model.ts @@ -22,6 +22,7 @@ import { ensureAuthProfileStore, resolveAuthProfileOrder } from "../auth-profile import type { AuthProfileCredential } from "../auth-profiles/types.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { resolveAgentHarnessPolicy } from "../harness/policy.js"; +import { resolveCatalogOwnedModelCompat } from "../model-compat-catalog.js"; import { resolveModelWorkspaceDir } from "../model-discovery-context.js"; import { modelKey, normalizeStaticProviderModelId } from "../model-ref-shared.js"; import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js"; @@ -499,7 +500,12 @@ function mergeStaticCatalogInlineModel( if (!staticCatalogModel) { return inlineModel; } - const compat = mergeModelCompat(staticCatalogModel.compat, inlineModel.compat); + const compat = resolveCatalogOwnedModelCompat({ + catalogRoute: staticCatalogModel, + catalogCompat: staticCatalogModel.compat, + configuredRoute: inlineModel, + configuredCompat: inlineModel.compat, + }); const mediaInput = mergeModelMediaInput(staticCatalogModel.mediaInput, inlineModel.mediaInput); const params = mergeModelParams( readModelParams(staticCatalogModel.params), @@ -820,13 +826,31 @@ function applyConfiguredProviderOverrides(params: { ? Math.min(resolvedMaxTokens, resolvedContextWindow) : resolvedMaxTokens : undefined; - const resolvedCompat = mergeModelCompat( - mergeModelCompat(configuredStaticCatalogModel?.compat, discoveredModel.compat), - metadataOverrideModel?.compat, + const catalogCompat = mergeModelCompat( + configuredStaticCatalogModel?.compat, + discoveredModel.compat, ); + const hasCatalogOwnedModel = + configuredStaticCatalogModel !== undefined || discoveredModel.maxTokensSource !== "configured"; + const resolvedCompat = resolveCatalogOwnedModelCompat({ + ...(hasCatalogOwnedModel + ? { + catalogRoute: { + api: discoveredModel.api ?? configuredStaticCatalogModel?.api, + baseUrl: discoveredModel.baseUrl ?? configuredStaticCatalogModel?.baseUrl, + }, + } + : {}), + catalogCompat, + configuredRoute: { + api: resolvedTransport.api, + baseUrl: resolvedTransport.baseUrl, + }, + configuredCompat: metadataOverrideModel?.compat, + }); const resolvedReasoning = resolveMergedConfiguredModelReasoning({ provider: params.provider, - configuredCompat: metadataOverrideModel?.compat, + configuredCompat: resolvedCompat, resolvedCompat, configuredReasoning: metadataOverrideModel?.reasoning, discoveredReasoning: discoveredModel.reasoning, @@ -1321,7 +1345,6 @@ function resolveConfiguredFallbackModel(params: { includeRuntimeDiscovery: true, }) as StaticCatalogFallbackModel | undefined; const metadataModel = configuredModel ?? staticCatalogModel; - const fallbackCompat = mergeModelCompat(staticCatalogModel?.compat, configuredModel?.compat); const fallbackMediaInput = mergeModelMediaInput( staticCatalogModel?.mediaInput, configuredModel?.mediaInput, @@ -1375,6 +1398,15 @@ function resolveConfiguredFallbackModel(params: { workspaceDir, runtimeHooks, }); + const fallbackCompat = resolveCatalogOwnedModelCompat({ + ...(staticCatalogModel ? { catalogRoute: staticCatalogModel } : {}), + catalogCompat: staticCatalogModel?.compat, + configuredRoute: { + api: fallbackTransport.api, + baseUrl: fallbackTransport.baseUrl, + }, + configuredCompat: configuredModel?.compat, + }); if ( configuredModel && shouldSuppressInlineConfiguredModel({ diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 2d20cee58008..095c09731dd8 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -125,6 +125,62 @@ describe("prepared model catalog builder", () => { expect(snapshot.routeVariants).toHaveLength(2); }); + it("keeps compat from the catalog route selected by config", async () => { + mocks.augmentModelCatalogWithProviderPlugins.mockResolvedValueOnce([ + { + id: "demo", + name: "Route B", + provider: "custom", + api: "openai-completions", + baseUrl: "https://route-b.example.test/v1", + compat: { supportsTools: false }, + }, + ]); + const snapshot = await build({ + config: { + plugins: { enabled: false }, + models: { + providers: { + custom: { + api: "openai-responses", + baseUrl: "https://route-a.example.test/v1", + models: [ + { + id: "demo", + name: "Configured Demo", + contextWindow: 32_000, + maxTokens: 4_096, + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }, + }, + }, + }, + entries: [ + { + id: "demo", + name: "Route A", + provider: "custom", + api: "openai-responses", + baseUrl: "https://route-a.example.test/v1", + compat: { supportsTools: true }, + }, + ], + readOnly: false, + }); + + expect( + findModelCatalogEntry(snapshot.entries, { provider: "custom", modelId: "demo" }), + ).toMatchObject({ + api: "openai-responses", + baseUrl: "https://route-a.example.test/v1", + compat: { supportsTools: true }, + }); + }); + it("keeps configured models absent from registry discovery", async () => { const snapshot = await build({ config: { diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index 6d889e6edb57..e59704bf6387 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -23,6 +23,7 @@ import type { ModelCatalogSnapshot, ModelInputType, } from "./model-catalog.types.js"; +import { resolveCatalogOwnedModelCompat } from "./model-compat-catalog.js"; import { modelKey, normalizeConfiguredProviderCatalogModelId, @@ -191,7 +192,11 @@ function clearRouteBoundCatalogMetadata(entry: ModelCatalogEntry): ModelCatalogE function overlayCatalogMetadata( base: ModelCatalogEntry, overlay: ModelCatalogEntry, - options?: { preserveBaseName?: boolean }, + options?: { + catalogCompatRoute?: ModelCatalogEntry; + preserveBaseCompat?: boolean; + preserveBaseName?: boolean; + }, ): ModelCatalogEntry { // Catalog rows with one logical provider/id may describe different physical // routes. Capabilities are atomic with their route; never carry them across @@ -210,7 +215,17 @@ function overlayCatalogMetadata( ...(overlay.input !== undefined ? { input: overlay.input } : {}), ...(params ? { params } : {}), ...(overlay.mediaInput !== undefined ? { mediaInput: overlay.mediaInput } : {}), - compat: mergeCatalogCompat(routeBase.compat, overlay.compat), + compat: options?.preserveBaseCompat + ? resolveCatalogOwnedModelCompat({ + catalogRoute: options.catalogCompatRoute ?? base, + catalogCompat: (options.catalogCompatRoute ?? base).compat, + configuredRoute: { + api: overlay.api ?? base.api, + baseUrl: overlay.baseUrl ?? base.baseUrl, + }, + configuredCompat: overlay.compat, + }) + : mergeCatalogCompat(routeBase.compat, overlay.compat), }; } @@ -227,7 +242,11 @@ function normalizeCatalogEntryContract(entry: ModelCatalogEntry): ModelCatalogEn function mergeCatalogEntries( models: ModelCatalogEntry[], entries: ModelCatalogEntry[], - options?: { preserveBaseName?: boolean }, + options?: { + catalogCompatRoutes?: readonly ModelCatalogEntry[]; + preserveBaseCompat?: boolean; + preserveBaseName?: boolean; + }, ): void { const indexByKey = new Map( models.map((entry, index) => [catalogEntryDedupeKey(entry.provider, entry.id), index]), @@ -242,7 +261,17 @@ function mergeCatalogEntries( } const existing = models.at(existingIndex); if (existing) { - models[existingIndex] = overlayCatalogMetadata(existing, entry, options); + // The logical row may currently represent a sibling physical route. Compat + // must come from the catalog variant selected by config, not that sibling. + const catalogCompatRoute = options?.preserveBaseCompat + ? options.catalogCompatRoutes?.find( + (candidate) => catalogRouteVariantKey(candidate) === catalogRouteVariantKey(entry), + ) + : undefined; + models[existingIndex] = overlayCatalogMetadata(existing, entry, { + ...options, + catalogCompatRoute, + }); } } } @@ -267,6 +296,7 @@ function createModelCatalogRouteVariantCollector(): ModelCatalogRouteVariantColl function mergeCatalogRouteVariants( collector: ModelCatalogRouteVariantCollector, entries: readonly ModelCatalogEntry[], + options?: { preserveBaseCompat?: boolean }, ): void { for (const entry of entries) { const key = catalogRouteVariantKey(entry); @@ -280,7 +310,7 @@ function mergeCatalogRouteVariants( if (existingEntry === undefined) { continue; } - collector.entries[existingIndex] = overlayCatalogMetadata(existingEntry, entry); + collector.entries[existingIndex] = overlayCatalogMetadata(existingEntry, entry, options); } } @@ -472,7 +502,11 @@ export async function buildPreparedModelCatalogSnapshot( let augmentEntries: ModelCatalogEntry[] | undefined; if (configuredModels.length > 0) { const entriesForAugment = [...models]; - mergeCatalogEntries(entriesForAugment, configuredModels, { preserveBaseName: true }); + mergeCatalogEntries(entriesForAugment, configuredModels, { + catalogCompatRoutes: routeVariants.entries, + preserveBaseCompat: true, + preserveBaseName: true, + }); augmentEntries = entriesForAugment; } logStage("configured-models-prepared", `entries=${models.length}`); @@ -520,8 +554,12 @@ export async function buildPreparedModelCatalogSnapshot( logStage("plugin-models-merged", `entries=${models.length}`); if (configuredModels.length > 0) { - mergeCatalogRouteVariants(routeVariants, configuredModels); - mergeCatalogEntries(models, configuredModels, { preserveBaseName: true }); + mergeCatalogRouteVariants(routeVariants, configuredModels, { preserveBaseCompat: true }); + mergeCatalogEntries(models, configuredModels, { + catalogCompatRoutes: routeVariants.entries, + preserveBaseCompat: true, + preserveBaseName: true, + }); } logStage("configured-models-finalized", `entries=${models.length}`); diff --git a/src/agents/model-compat-catalog.test.ts b/src/agents/model-compat-catalog.test.ts new file mode 100644 index 000000000000..9f3f440803c0 --- /dev/null +++ b/src/agents/model-compat-catalog.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + modelTransportRoutesMatch, + resolveCatalogOwnedModelCompat, + resolveUniqueCatalogModelRoute, +} from "./model-compat-catalog.js"; + +describe("catalog-owned model compat", () => { + const catalogRoute = { + api: "openai-responses", + baseUrl: "https://api.example.test/v1/", + }; + const catalogCompat = { supportsTools: true, supportsTemperature: false }; + + it("uses catalog capabilities when config keeps the catalog route", () => { + expect( + resolveCatalogOwnedModelCompat({ + catalogRoute, + catalogCompat, + configuredRoute: { + api: "openai-responses", + baseUrl: "https://api.example.test/v1", + }, + configuredCompat: { supportsTools: false, supportsTemperature: true }, + }), + ).toEqual(catalogCompat); + }); + + it("uses configured capabilities only when config selects a custom route", () => { + const configuredCompat = { supportsTools: false }; + + expect( + resolveCatalogOwnedModelCompat({ + catalogRoute, + catalogCompat, + configuredRoute: { baseUrl: "http://127.0.0.1:9000/v1" }, + configuredCompat, + }), + ).toEqual(configuredCompat); + }); + + it("treats missing configured route fields and trailing slashes as the catalog route", () => { + expect(modelTransportRoutesMatch(catalogRoute, {})).toBe(true); + expect(modelTransportRoutesMatch(catalogRoute, { api: " ", baseUrl: " " })).toBe(true); + expect( + modelTransportRoutesMatch(catalogRoute, { + api: "OPENAI-RESPONSES", + baseUrl: "https://api.example.test/v1", + }), + ).toBe(true); + }); + + it("requires one matching physical route before destructive cleanup", () => { + const routeA = { + api: "openai-responses", + baseUrl: "https://route-a.example.test/v1", + }; + const routeB = { + api: "openai-completions", + baseUrl: "https://route-b.example.test/v1", + }; + + expect(resolveUniqueCatalogModelRoute([routeA, routeB], {})).toBeUndefined(); + expect(resolveUniqueCatalogModelRoute([routeA, routeB], routeA)).toBe(routeA); + }); +}); diff --git a/src/agents/model-compat-catalog.ts b/src/agents/model-compat-catalog.ts new file mode 100644 index 000000000000..3ec2e90f1414 --- /dev/null +++ b/src/agents/model-compat-catalog.ts @@ -0,0 +1,72 @@ +import type { ModelCompatConfig } from "../config/types.models.js"; + +type ModelTransportRoute = { + api?: unknown; + baseUrl?: unknown; +}; + +function normalizeApi(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function normalizeBaseUrl(value: unknown): string { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + try { + const url = new URL(trimmed); + url.pathname = url.pathname.replace(/\/+$/u, "") || "/"; + return url.toString(); + } catch { + return trimmed.replace(/\/+$/u, ""); + } +} + +export function modelTransportRoutesMatch( + catalogRoute: ModelTransportRoute, + configuredRoute: ModelTransportRoute, +): boolean { + const catalogApi = normalizeApi(catalogRoute.api); + const catalogBaseUrl = normalizeBaseUrl(catalogRoute.baseUrl); + return ( + (normalizeApi(configuredRoute.api) || catalogApi) === catalogApi && + (normalizeBaseUrl(configuredRoute.baseUrl) || catalogBaseUrl) === catalogBaseUrl + ); +} + +/** Returns one unambiguous physical catalog route for destructive config cleanup. */ +export function resolveUniqueCatalogModelRoute( + catalogRoutes: readonly T[] | undefined, + configuredRoute: ModelTransportRoute, +): T | undefined { + let match: T | undefined; + for (const route of catalogRoutes ?? []) { + if (!modelTransportRoutesMatch(route, configuredRoute)) { + continue; + } + if (match) { + return undefined; + } + match = route; + } + return match; +} + +/** Capabilities belong to the catalog route; config owns them only for a different/custom route. */ +export function resolveCatalogOwnedModelCompat(params: { + catalogRoute?: ModelTransportRoute; + catalogCompat?: ModelCompatConfig; + configuredRoute?: ModelTransportRoute; + configuredCompat?: ModelCompatConfig; +}): ModelCompatConfig | undefined { + if (!params.catalogRoute) { + return params.configuredCompat; + } + return modelTransportRoutesMatch(params.catalogRoute, params.configuredRoute ?? {}) + ? params.catalogCompat + : params.configuredCompat; +} diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index 6e7f1f826b16..bf334d6c9906 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -22,6 +22,7 @@ import { resolveConfiguredProviderFallback } from "./configured-provider-fallbac import { DEFAULT_PROVIDER } from "./defaults.js"; import { findModelCatalogEntry } from "./model-catalog-lookup.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; +import { resolveCatalogOwnedModelCompat } from "./model-compat-catalog.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; import { normalizeConfiguredProviderCatalogModelId, @@ -706,10 +707,12 @@ function applyModelCatalogMetadata(params: { params.entry.params || configuredEntry?.params ? { ...params.entry.params, ...configuredEntry?.params } : undefined; - const nextCompat = - params.entry.compat || configuredEntry?.compat - ? { ...params.entry.compat, ...configuredEntry?.compat } - : undefined; + const nextCompat = resolveCatalogOwnedModelCompat({ + catalogRoute: params.entry, + catalogCompat: params.entry.compat, + configuredRoute: configuredEntry, + configuredCompat: configuredEntry?.compat, + }); return { ...params.entry, diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index e3d01d848c14..20eba96716b9 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -1153,7 +1153,7 @@ describe("model-selection", () => { ]); }); - it("overlays configured provider metadata after manifest model normalization", () => { + it("keeps compat catalog-owned while overlaying metadata after manifest normalization", () => { const cfg: OpenClawConfig = { models: { providers: { @@ -1185,7 +1185,6 @@ describe("model-selection", () => { name: "Configured Llama Fast", contextWindow: 128_000, reasoning: true, - compat: { thinkingFormat: "qwen" }, }, ]); }); diff --git a/src/agents/models-config.merge.test.ts b/src/agents/models-config.merge.test.ts index ab41d1f45266..e4230fe09c16 100644 --- a/src/agents/models-config.merge.test.ts +++ b/src/agents/models-config.merge.test.ts @@ -145,6 +145,45 @@ describe("models-config merge helpers", () => { ]); }); + it("keeps compat catalog-owned for a configured model on the catalog route", () => { + const implicit = createConfigProvider({ + baseUrl: "https://catalog.example/v1/", + models: [ + createModel({ + compat: { supportsTools: true, supportsTemperature: false }, + }), + ], + }); + const explicit = createConfigProvider({ + baseUrl: "https://catalog.example/v1", + models: [ + createModel({ + compat: { supportsTools: false, supportsTemperature: true }, + }), + ], + }); + + expect(mergeProviderModels(implicit, explicit).models?.[0]?.compat).toEqual({ + supportsTools: true, + supportsTemperature: false, + }); + }); + + it("preserves custom compat when config changes the catalog route", () => { + const implicit = createConfigProvider({ + baseUrl: "https://catalog.example/v1", + models: [createModel({ compat: { supportsTools: true } })], + }); + const explicit = createConfigProvider({ + baseUrl: "http://127.0.0.1:9000/v1", + models: [createModel({ compat: { supportsTools: false } })], + }); + + expect(mergeProviderModels(implicit, explicit).models?.[0]?.compat).toEqual({ + supportsTools: false, + }); + }); + it("merges explicit providers onto trimmed keys", () => { const merged = mergeProviders({ explicit: { diff --git a/src/agents/models-config.merge.ts b/src/agents/models-config.merge.ts index 01b8b7986b05..f5ecf11a772e 100644 --- a/src/agents/models-config.merge.ts +++ b/src/agents/models-config.merge.ts @@ -5,6 +5,7 @@ */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { isNonSecretApiKeyMarker } from "./model-auth-markers.js"; +import { resolveCatalogOwnedModelCompat } from "./model-compat-catalog.js"; import { normalizeProviderMapKeys } from "./models-config.providers.keys.js"; import type { ProviderConfig } from "./models-config.providers.secrets.js"; @@ -104,6 +105,19 @@ export function mergeProviderModels( explicitValue: explicitModel.maxTokens, implicitValue: implicitModel.maxTokens, }); + const compat = resolveCatalogOwnedModelCompat({ + catalogRoute: { + api: implicitModel.api ?? implicit.api, + baseUrl: implicitModel.baseUrl ?? implicit.baseUrl, + }, + catalogCompat: implicitModel.compat, + configuredRoute: { + api: explicitModel.api ?? explicit.api ?? implicitModel.api ?? implicit.api, + baseUrl: + explicitModel.baseUrl ?? explicit.baseUrl ?? implicitModel.baseUrl ?? implicit.baseUrl, + }, + configuredCompat: explicitModel.compat, + }); return Object.assign( {}, @@ -115,6 +129,7 @@ export function mergeProviderModels( contextWindow === undefined ? {} : { contextWindow }, contextTokens === undefined ? {} : { contextTokens }, maxTokens === undefined ? {} : { maxTokens }, + { compat }, ); }); diff --git a/src/agents/tools-effective-inventory.test.ts b/src/agents/tools-effective-inventory.test.ts index f32c86e81954..a04e6f6d4a01 100644 --- a/src/agents/tools-effective-inventory.test.ts +++ b/src/agents/tools-effective-inventory.test.ts @@ -1018,7 +1018,7 @@ describe("resolveEffectiveToolInventory", () => { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128_000, maxTokens: 8_192, - compat: { supportsTools: true, nativeWebSearchTool: true }, + compat: { supportsTools: true }, }, ], }, @@ -1033,10 +1033,7 @@ describe("resolveEffectiveToolInventory", () => { expect(createToolsMock).toHaveBeenCalledTimes(1); const createToolsOptions = createToolsMock.mock.calls.at(0)?.[0]; expect(createToolsOptions?.allowGatewaySubagentBinding).toBe(true); - expect(createToolsOptions?.modelCompat).toEqual({ - supportsTools: true, - nativeWebSearchTool: true, - }); + expect(createToolsOptions?.modelCompat).toEqual({ supportsTools: true }); expect(createToolsOptions?.modelApi).toBe("openai-completions"); }); }); diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 3bb744bd37cc..7cdbdb42b606 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -404,7 +404,7 @@ describe("createModelSelectionState catalog loading", () => { expect(loadModelCatalogLocal).not.toHaveBeenCalled(); }); - it("keeps configured compat when runtime thinking catalog is already loaded", async () => { + it("uses only configured compat for a custom route when the catalog is loaded", async () => { vi.mocked(loadModelCatalogLocal).mockClear(); vi.mocked(loadModelCatalogLocal).mockResolvedValueOnce([ { @@ -454,10 +454,7 @@ describe("createModelSelectionState catalog loading", () => { provider: "vllm", id: "Qwen/Qwen3-8B", reasoning: true, - compat: { - supportedReasoningEfforts: ["xhigh"], - thinkingFormat: "qwen-chat-template", - }, + compat: { thinkingFormat: "qwen-chat-template" }, }), ]); expect(loadModelCatalogLocal).toHaveBeenCalledOnce(); diff --git a/src/commands/doctor/shared/deprecation-compat.ts b/src/commands/doctor/shared/deprecation-compat.ts index e900ad4fff11..275a60db969b 100644 --- a/src/commands/doctor/shared/deprecation-compat.ts +++ b/src/commands/doctor/shared/deprecation-compat.ts @@ -78,6 +78,22 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ "src/config/dead-config-keys.test.ts", ], }), + deprecatedCompatRecord({ + code: "doctor-model-compat-catalog-ownership", + deprecated: "2026-07-21", + warningStarts: "2026-07-21", + removeAfter: "2026-09-22", + owner: "provider", + introduced: "2026-07-21", + source: "model compat capability ownership moved from known-model config to provider catalogs", + migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts", + replacement: "provider catalog compat metadata, with config compat reserved for custom routes", + docsPath: "/gateway/config-tools", + tests: [ + "src/commands/doctor/shared/legacy-config-migrations.runtime.models.test.ts", + "src/config/dead-config-keys.test.ts", + ], + }), deprecatedCompatRecord({ code: "doctor-tier-eval-tranche", deprecated: "2026-07-20", diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.test.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.test.ts index bad05ac8216f..4c4237018cb4 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.test.ts @@ -10,6 +10,83 @@ import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS, } from "./legacy-config-migrations.runtime.models.js"; +describe("model compat catalog ownership migration", () => { + const migration = LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS.find( + (entry) => entry.id === "models.providers.*.models.*.compat->provider-catalog", + ); + + it("strips matching catalog values and dead keys while preserving divergences", () => { + const raw = { + models: { + providers: { + openai: { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + models: [ + { + id: "gpt-5.6", + compat: { + supportsReasoningEffort: true, + supportsTemperature: true, + nativeWebSearchTool: true, + requiresMistralToolIds: true, + }, + }, + ], + }, + }, + }, + }; + const changes: string[] = []; + const rules = migration?.legacyRules ?? []; + + expect(rules.map((rule) => rule.match?.(raw.models.providers, raw))).toEqual([ + true, + true, + true, + ]); + migration?.apply(raw, changes); + + expect(raw.models.providers.openai.models[0]?.compat).toEqual({ supportsTemperature: true }); + expect(changes).toEqual([ + "Removed models.providers.openai.models.0.compat catalog/dead overrides: nativeWebSearchTool, requiresMistralToolIds, supportsReasoningEffort.", + ]); + expect(rules.map((rule) => rule.match?.(raw.models.providers, raw))).toEqual([ + false, + false, + true, + ]); + }); + + it("preserves live compat for custom models and custom routes", () => { + const raw = { + models: { + providers: { + custom: { + api: "openai-completions", + baseUrl: "http://127.0.0.1:9000/v1", + models: [{ id: "local-model", compat: { supportsTools: false } }], + }, + openai: { + api: "openai-responses", + baseUrl: "http://127.0.0.1:9100/v1", + models: [{ id: "gpt-5.6", compat: { supportsReasoningEffort: true } }], + }, + }, + }, + }; + const changes: string[] = []; + + migration?.apply(raw, changes); + + expect(raw.models.providers.custom.models[0]?.compat).toEqual({ supportsTools: false }); + expect(raw.models.providers.openai.models[0]?.compat).toEqual({ + supportsReasoningEffort: true, + }); + expect(changes).toEqual([]); + }); +}); + describe("explicit model allow policy migration", () => { const migration = LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS.find( (entry) => entry.id === "agents.defaults.models->agents.defaults.modelPolicy.allow", diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts index 04e412c46db2..a353da65f542 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts @@ -1,7 +1,15 @@ // Legacy model runtime config migrations for stale model refs, compat fields, and catalog data. import { isDeepStrictEqual } from "node:util"; +import type { + ModelCatalog, + NormalizedModelCatalogRow, +} from "@openclaw/model-catalog-core/model-catalog-types"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeOptionalAgentRuntimeId } from "../../../agents/agent-runtime-id.js"; +import { + modelTransportRoutesMatch, + resolveUniqueCatalogModelRoute, +} from "../../../agents/model-compat-catalog.js"; import { splitTrailingAuthProfile } from "../../../agents/model-ref-profile.js"; import { defineLegacyConfigMigration, @@ -17,6 +25,8 @@ import { } from "../../../config/model-policy-allowlist-migration.js"; import { isModelThinkingFormat, type ModelDefinitionConfig } from "../../../config/types.models.js"; import { isBlockedObjectKey } from "../../../infra/prototype-keys.js"; +import { planManifestModelCatalogRows } from "../../../model-catalog/manifest-planner.js"; +import { listOpenClawPluginManifestMetadata } from "../../../plugins/manifest-metadata-scan.js"; import { isLegacyCodexProviderId, legacyCodexProviderIdentityKey, @@ -42,6 +52,212 @@ const STALE_CONTEXT_WINDOW_FIXES: Record(); +let modelCompatCatalogPlugins: + | Array<{ id: string; modelCatalog: ModelCatalog; providers: string[] }> + | undefined; + +function getModelCompatCatalogPlugins() { + modelCompatCatalogPlugins ??= listOpenClawPluginManifestMetadata().flatMap(({ manifest }) => { + const id = typeof manifest.id === "string" ? manifest.id.trim() : ""; + const modelCatalog = getRecord(manifest.modelCatalog); + if (!id || !modelCatalog) { + return []; + } + return [ + { + id, + providers: Array.isArray(manifest.providers) + ? manifest.providers.filter((value): value is string => typeof value === "string") + : [], + modelCatalog: modelCatalog as ModelCatalog, + }, + ]; + }); + return modelCompatCatalogPlugins; +} + +function buildConfiguredProviderCatalogRows( + providers: Record, +): Map { + const rows = new Map(); + for (const providerId of Object.keys(providers)) { + const normalizedProviderId = normalizeProviderId(providerId); + let providerRows = modelCompatCatalogRowsByProvider.get(normalizedProviderId); + if (!providerRows) { + providerRows = planManifestModelCatalogRows({ + registry: { plugins: getModelCompatCatalogPlugins() }, + providerFilter: normalizedProviderId, + }).rows; + modelCompatCatalogRowsByProvider.set(normalizedProviderId, providerRows); + } + for (const row of providerRows) { + const key = normalizedCatalogModelKey(row.provider, row.id); + const variants = rows.get(key) ?? []; + variants.push(row); + rows.set(key, variants); + } + } + return rows; +} + +function inspectModelCompatOverrides( + providersValue: unknown, + onEntry?: (params: { + catalogRow?: NormalizedModelCatalogRow; + compat: Record; + model: Record; + modelIndex: number; + provider: Record; + providerId: string; + state: ModelCompatOverrideState; + }) => void, +): ModelCompatOverrideState { + const providers = getRecord(providersValue); + const total = { dead: 0, divergent: 0, matching: 0 }; + if (!providers) { + return total; + } + const hasCompat = Object.values(providers).some((providerValue) => { + const models = getRecord(providerValue)?.models; + return ( + Array.isArray(models) && + models.some((modelValue) => Boolean(getRecord(getRecord(modelValue)?.compat))) + ); + }); + if (!hasCompat) { + return total; + } + const catalogRows = buildConfiguredProviderCatalogRows(providers); + for (const [providerId, providerValue] of Object.entries(providers)) { + const provider = getRecord(providerValue); + const models = provider?.models; + if (!provider || !Array.isArray(models)) { + continue; + } + for (const [modelIndex, modelValue] of models.entries()) { + const model = getRecord(modelValue); + const compat = getRecord(model?.compat); + const modelId = typeof model?.id === "string" ? model.id : ""; + if (!model || !compat || !modelId) { + continue; + } + const state = { dead: 0, divergent: 0, matching: 0 }; + for (const key of DEAD_MODEL_COMPAT_KEYS) { + if (Object.hasOwn(compat, key)) { + state.dead += 1; + } + } + const configuredRoute = { + api: model.api ?? provider.api, + baseUrl: model.baseUrl ?? provider.baseUrl, + }; + const catalogRow = resolveUniqueCatalogModelRoute( + catalogRows.get(normalizedCatalogModelKey(providerId, modelId)), + configuredRoute, + ); + const catalogRouteMatches = catalogRow !== undefined; + if (catalogRouteMatches) { + const catalogCompat = catalogRow.compat ?? {}; + for (const [key, value] of Object.entries(compat)) { + if ((DEAD_MODEL_COMPAT_KEYS as readonly string[]).includes(key)) { + continue; + } + if (isDeepStrictEqual(value, catalogCompat[key as keyof typeof catalogCompat])) { + state.matching += 1; + } else { + state.divergent += 1; + } + } + } + total.dead += state.dead; + total.divergent += state.divergent; + total.matching += state.matching; + onEntry?.({ catalogRow, compat, model, modelIndex, provider, providerId, state }); + } + } + return total; +} + +const MODEL_COMPAT_CATALOG_RULES: LegacyConfigRule[] = [ + { + path: ["models", "providers"], + message: + 'nativeWebSearchTool and requiresMistralToolIds are unused and retired; run "openclaw doctor --fix" to remove them.', + match: (value) => inspectModelCompatOverrides(value).dead > 0, + }, + { + path: ["models", "providers"], + message: + 'Catalog-known model compat values are provider-owned; run "openclaw doctor --fix" to remove matching config overrides.', + match: (value) => inspectModelCompatOverrides(value).matching > 0, + }, + { + path: ["models", "providers"], + message: + "Catalog-known model compat differs from the provider catalog and was preserved for review. Use a distinct custom route when the endpoint really has different capabilities.", + match: (value) => inspectModelCompatOverrides(value).divergent > 0, + }, +]; + +function migrateModelCompatCatalogOwnership(raw: Record, changes: string[]): void { + const providers = getRecord(getRecord(raw.models)?.providers); + inspectModelCompatOverrides( + providers, + ({ catalogRow, compat, model, modelIndex, provider, providerId }) => { + const removed: string[] = []; + for (const key of DEAD_MODEL_COMPAT_KEYS) { + if (Object.hasOwn(compat, key)) { + delete compat[key]; + removed.push(key); + } + } + if ( + catalogRow && + modelTransportRoutesMatch(catalogRow, { + api: model.api ?? provider.api ?? catalogRow.api, + baseUrl: model.baseUrl ?? provider.baseUrl ?? catalogRow.baseUrl, + }) + ) { + const catalogCompat = catalogRow.compat ?? {}; + for (const [key, value] of Object.entries(compat)) { + if (isDeepStrictEqual(value, catalogCompat[key as keyof typeof catalogCompat])) { + delete compat[key]; + removed.push(key); + } + } + } + if (removed.length === 0) { + return; + } + if (Object.keys(compat).length === 0) { + delete model.compat; + } + changes.push( + `Removed models.providers.${providerId}.models.${modelIndex}.compat catalog/dead overrides: ${removed.toSorted().join(", ")}.`, + ); + }, + ); +} + function resolveStaleContextWindowFix(params: { providerId: string; modelId: string; @@ -1736,6 +1952,12 @@ const LEGACY_DEFAULT_MODEL_MIGRATION = defineLegacyConfigMigration({ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS: LegacyConfigMigrationSpec[] = [ LEGACY_DEFAULT_MODEL_MIGRATION, + defineLegacyConfigMigration({ + id: "models.providers.*.models.*.compat->provider-catalog", + describe: "Move known-model compatibility capability ownership into provider catalogs", + legacyRules: MODEL_COMPAT_CATALOG_RULES, + apply: migrateModelCompatCatalogOwnership, + }), defineLegacyConfigMigration({ id: "models.providers.codex-routes->models.providers.openai", describe: "Move legacy Codex-route provider config to canonical OpenAI provider config", diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index e726732eb70e..890150e4d541 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -1079,7 +1079,6 @@ describe("model compat config schema", () => { requiresToolResultName: true, requiresAssistantAfterToolResult: false, requiresThinkingAsText: false, - requiresMistralToolIds: false, requiresOpenAiAnthropicToolPayload: true, }, }, diff --git a/src/config/dead-config-keys.test.ts b/src/config/dead-config-keys.test.ts index 9be9f0327910..083a64c77f45 100644 --- a/src/config/dead-config-keys.test.ts +++ b/src/config/dead-config-keys.test.ts @@ -192,6 +192,8 @@ describe("dead config keys", () => { "cloudWorkers.profiles.default.lifetime", "mcp.servers.docs.workingDirectory", "nodeHost.mcp.servers.docs.workingDirectory", + "models.providers.custom.models.0.compat.requiresMistralToolIds", + "models.providers.custom.models.0.compat.nativeWebSearchTool", ] as const)("rejects retired tuning knob %s", (fullPath) => { const segments = fullPath.split("."); const key = segments.pop() ?? ""; diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 4912eb18cb08..5f66a5b21555 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -102,12 +102,8 @@ export type ModelCompatConfig = SupportedOpenAICompatFields & toolSchemaProfile?: string; /** JSON Schema keywords rejected by this provider's tool schema validator. */ unsupportedToolSchemaKeywords?: string[]; - /** Whether this model/provider exposes a native web search tool. */ - nativeWebSearchTool?: boolean; /** Encoding expected for tool-call arguments in provider payloads. */ toolCallArgumentsEncoding?: string; - /** Whether Mistral-compatible tool-call ids must be generated/normalized. */ - requiresMistralToolIds?: boolean; /** Whether OpenAI-style calls must be reshaped to Anthropic-compatible tool payloads. */ requiresOpenAiAnthropicToolPayload?: boolean; }; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 1fd6fcd696af..e7cfef97d621 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -226,9 +226,7 @@ const ModelCompatSchema = z requiresReasoningContentOnAssistantMessages: z.boolean().optional(), toolSchemaProfile: z.string().optional(), unsupportedToolSchemaKeywords: z.array(z.string().min(1)).optional(), - nativeWebSearchTool: z.boolean().optional(), toolCallArgumentsEncoding: z.string().optional(), - requiresMistralToolIds: z.boolean().optional(), requiresOpenAiAnthropicToolPayload: z.boolean().optional(), }) .strict() diff --git a/src/plugin-sdk/provider-model-shared.ts b/src/plugin-sdk/provider-model-shared.ts index 13ca2cd8559c..6bd76c0a9bb6 100644 --- a/src/plugin-sdk/provider-model-shared.ts +++ b/src/plugin-sdk/provider-model-shared.ts @@ -76,7 +76,6 @@ export { resolveProviderEndpoint } from "../agents/provider-attribution.js"; export { applyModelCompatPatch, hasToolSchemaProfile, - hasNativeWebSearchTool, normalizeModelCompat, resolveUnsupportedToolSchemaKeywords, resolveToolCallArgumentsEncoding, diff --git a/src/plugin-sdk/test-helpers/provider-runtime-contract.ts b/src/plugin-sdk/test-helpers/provider-runtime-contract.ts index a2db2a85b84e..2c0ebd5851c8 100644 --- a/src/plugin-sdk/test-helpers/provider-runtime-contract.ts +++ b/src/plugin-sdk/test-helpers/provider-runtime-contract.ts @@ -759,7 +759,6 @@ export function describeVeniceProviderRuntimeContract(load: ProviderRuntimeContr }); const compat = requireRecord(model?.compat, "compat"); expect(compat.toolSchemaProfile).toBe("xai"); - expect(compat.nativeWebSearchTool).toBe(true); expect(compat.toolCallArgumentsEncoding).toBe("html-entities"); }); }); diff --git a/src/plugins/provider-model-compat.ts b/src/plugins/provider-model-compat.ts index 448346a9b666..54ba6b67b2bf 100644 --- a/src/plugins/provider-model-compat.ts +++ b/src/plugins/provider-model-compat.ts @@ -45,12 +45,6 @@ export function hasToolSchemaProfile( return extractModelCompat(modelOrCompat)?.toolSchemaProfile === profile; } -export function hasNativeWebSearchTool( - modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined, -): boolean { - return extractModelCompat(modelOrCompat)?.nativeWebSearchTool === true; -} - export function resolveToolCallArgumentsEncoding( modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined, ): ModelCompatConfig["toolCallArgumentsEncoding"] | undefined {