mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(models): own compat in provider catalogs (#112542)
This commit is contained in:
committed by
GitHub
parent
0b080b9c2e
commit
5808b72ed6
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"core": 2305,
|
||||
"core": 2303,
|
||||
"channel": 3627,
|
||||
"plugin": 3556
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.<id>` 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.<id>.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.<provider>.models[]` with at least the matching `id`; use `agents.defaults.modelPolicy.allow` separately when you want an override restriction.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Amazon Bedrock discovery">
|
||||
|
||||
@@ -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.<provider>.models[].compat.requiresStringContent: true`.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -39,7 +39,6 @@ describe("venice provider plugin", () => {
|
||||
"minContains",
|
||||
"maxContains",
|
||||
],
|
||||
nativeWebSearchTool: true,
|
||||
toolCallArgumentsEncoding: "html-entities",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ function applyXaiModelCompat<T extends { compat?: unknown }>(model: T): T {
|
||||
return applyModelCompatPatch(model as T & { compat?: ModelCompatConfig }, {
|
||||
toolSchemaProfile: "xai",
|
||||
unsupportedToolSchemaKeywords: [...XAI_UNSUPPORTED_SCHEMA_KEYWORDS],
|
||||
nativeWebSearchTool: true,
|
||||
toolCallArgumentsEncoding: "html-entities",
|
||||
}) as T;
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -390,8 +390,6 @@ function normalizeModelCatalogCompat(value: unknown): ModelCatalogCompatConfig |
|
||||
"sendSessionIdHeader",
|
||||
"supportsEagerToolInputStreaming",
|
||||
"supportsLongCacheRetention",
|
||||
"nativeWebSearchTool",
|
||||
"requiresMistralToolIds",
|
||||
"requiresOpenAiAnthropicToolPayload",
|
||||
] as const;
|
||||
for (const field of booleanFields) {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -1793,7 +1793,6 @@ describe("createOpenClawCodingTools", () => {
|
||||
modelCompat: {
|
||||
toolSchemaProfile: "xai",
|
||||
unsupportedToolSchemaKeywords: Array.from(XAI_UNSUPPORTED_SCHEMA_KEYWORDS),
|
||||
nativeWebSearchTool: true,
|
||||
toolCallArgumentsEncoding: "html-entities",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<T extends ModelTransportRoute>(
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, { stale: number; correct: numbe
|
||||
"xai/grok-4.20-non-reasoning": { stale: 2_000_000, correct: 1_000_000 },
|
||||
} as const;
|
||||
|
||||
const DEAD_MODEL_COMPAT_KEYS = ["nativeWebSearchTool", "requiresMistralToolIds"] as const;
|
||||
|
||||
type ModelCompatOverrideState = {
|
||||
dead: number;
|
||||
divergent: number;
|
||||
matching: number;
|
||||
};
|
||||
|
||||
function normalizedCatalogModelKey(provider: string, modelId: string): string {
|
||||
// Keep doctor identity aligned with runtime catalog lookup and merge keys,
|
||||
// which intentionally treat provider/model ids case-insensitively.
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
const normalizedId = modelId.trim().toLowerCase();
|
||||
const providerPrefix = `${normalizedProvider}/`;
|
||||
return `${normalizedProvider}::${normalizedId.startsWith(providerPrefix) ? normalizedId.slice(providerPrefix.length) : normalizedId}`;
|
||||
}
|
||||
|
||||
// Manifest metadata is process-stable; plugin installs/reloads restart the owning process.
|
||||
const modelCompatCatalogRowsByProvider = new Map<string, readonly NormalizedModelCatalogRow[]>();
|
||||
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<string, unknown>,
|
||||
): Map<string, NormalizedModelCatalogRow[]> {
|
||||
const rows = new Map<string, NormalizedModelCatalogRow[]>();
|
||||
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<string, unknown>;
|
||||
model: Record<string, unknown>;
|
||||
modelIndex: number;
|
||||
provider: Record<string, unknown>;
|
||||
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<string, unknown>, 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",
|
||||
|
||||
@@ -1079,7 +1079,6 @@ describe("model compat config schema", () => {
|
||||
requiresToolResultName: true,
|
||||
requiresAssistantAfterToolResult: false,
|
||||
requiresThinkingAsText: false,
|
||||
requiresMistralToolIds: false,
|
||||
requiresOpenAiAnthropicToolPayload: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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() ?? "";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -76,7 +76,6 @@ export { resolveProviderEndpoint } from "../agents/provider-attribution.js";
|
||||
export {
|
||||
applyModelCompatPatch,
|
||||
hasToolSchemaProfile,
|
||||
hasNativeWebSearchTool,
|
||||
normalizeModelCompat,
|
||||
resolveUnsupportedToolSchemaKeywords,
|
||||
resolveToolCallArgumentsEncoding,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user