refactor(models): own compat in provider catalogs (#112542)

This commit is contained in:
Peter Steinberger
2026-07-22 02:14:29 -07:00
committed by GitHub
parent 0b080b9c2e
commit 5808b72ed6
38 changed files with 736 additions and 126 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"core": 2305,
"core": 2303,
"channel": 3627,
"plugin": 3556
}
+2 -2
View File
@@ -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
+2
View File
@@ -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.
+32 -5
View File
@@ -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">
+2
View File
@@ -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`.
-16
View File
@@ -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",
},
},
],
},
-4
View File
@@ -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",
-40
View File
@@ -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",
-1
View File
@@ -39,7 +39,6 @@ describe("venice provider plugin", () => {
"minContains",
"maxContains",
],
nativeWebSearchTool: true,
toolCallArgumentsEncoding: "html-entities",
},
});
-1
View File
@@ -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;
}
-2
View File
@@ -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"]);
});
-1
View File
@@ -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,
},
});
+36 -1
View File
@@ -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: {
+38 -6
View File
@@ -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({
+56
View File
@@ -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: {
+46 -8
View File
@@ -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}`);
+66
View File
@@ -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);
});
});
+72
View File
@@ -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;
}
+7 -4
View File
@@ -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,
+1 -2
View File
@@ -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" },
},
]);
});
+39
View File
@@ -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: {
+15
View File
@@ -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 },
);
});
+2 -5
View File
@@ -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");
});
});
+2 -5
View File
@@ -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",
-1
View File
@@ -1079,7 +1079,6 @@ describe("model compat config schema", () => {
requiresToolResultName: true,
requiresAssistantAfterToolResult: false,
requiresThinkingAsText: false,
requiresMistralToolIds: false,
requiresOpenAiAnthropicToolPayload: true,
},
},
+2
View File
@@ -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() ?? "";
-4
View File
@@ -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;
};
-2
View File
@@ -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()
-1
View File
@@ -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");
});
});
-6
View File
@@ -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 {