diff --git a/extensions/mistral/api.ts b/extensions/mistral/api.ts index 524af98ef4e..aab786b6eed 100644 --- a/extensions/mistral/api.ts +++ b/extensions/mistral/api.ts @@ -12,24 +12,32 @@ export { const MISTRAL_MAX_TOKENS_FIELD = "max_tokens"; +export const MISTRAL_MODEL_COMPAT_PATCH = { + supportsStore: false, + supportsReasoningEffort: false, + maxTokensField: MISTRAL_MAX_TOKENS_FIELD, +} as const satisfies { + supportsStore: boolean; + supportsReasoningEffort: boolean; + maxTokensField: "max_tokens"; +}; + export function applyMistralModelCompat(model: T): T { - const patch = { - supportsStore: false, - supportsReasoningEffort: false, - maxTokensField: MISTRAL_MAX_TOKENS_FIELD, - } satisfies Record; const compat = model.compat && typeof model.compat === "object" ? (model.compat as Record) : undefined; - if (compat && Object.entries(patch).every(([key, value]) => compat[key] === value)) { + if ( + compat && + Object.entries(MISTRAL_MODEL_COMPAT_PATCH).every(([key, value]) => compat[key] === value) + ) { return model; } return { ...model, compat: { ...compat, - ...patch, + ...MISTRAL_MODEL_COMPAT_PATCH, } as T extends { compat?: infer TCompat } ? TCompat : never, } as T; } diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index cd02f044d68..e55c37bb2ff 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -1,10 +1,51 @@ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; -import { applyMistralModelCompat } from "./api.js"; +import { applyMistralModelCompat, MISTRAL_MODEL_COMPAT_PATCH } from "./api.js"; import { mistralMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildMistralProvider } from "./provider-catalog.js"; const PROVIDER_ID = "mistral"; +const MISTRAL_MODEL_HINTS = [ + "mistral", + "mistralai", + "mixtral", + "codestral", + "pixtral", + "devstral", + "ministral", +] as const; + +function isMistralBaseUrl(baseUrl: unknown): boolean { + if (typeof baseUrl !== "string" || !baseUrl.trim()) { + return false; + } + try { + return new URL(baseUrl).hostname.toLowerCase() === "api.mistral.ai"; + } catch { + return baseUrl.toLowerCase().includes("api.mistral.ai"); + } +} + +function isMistralModelHint(modelId: string): boolean { + const normalized = modelId.trim().toLowerCase(); + return MISTRAL_MODEL_HINTS.some( + (hint) => + normalized === hint || + normalized.startsWith(`${hint}/`) || + normalized.startsWith(`${hint}-`) || + normalized.startsWith(`${hint}:`), + ); +} + +function shouldContributeMistralCompat(params: { + modelId: string; + model: { api?: unknown; baseUrl?: unknown }; +}): boolean { + if (params.model.api !== "openai-completions") { + return false; + } + return isMistralBaseUrl(params.model.baseUrl) || isMistralModelHint(params.modelId); +} export default defineSingleProviderPluginEntry({ id: PROVIDER_ID, @@ -34,6 +75,8 @@ export default defineSingleProviderPluginEntry({ allowExplicitBaseUrl: true, }, normalizeResolvedModel: ({ model }) => applyMistralModelCompat(model), + contributeResolvedModelCompat: ({ modelId, model }) => + shouldContributeMistralCompat({ modelId, model }) ? MISTRAL_MODEL_COMPAT_PATCH : undefined, capabilities: { transcriptToolCallIdMode: "strict9", transcriptToolCallIdModelHints: [ diff --git a/src/agents/pi-embedded-runner/model.startup-retry.test.ts b/src/agents/pi-embedded-runner/model.startup-retry.test.ts index 1cfa0ae4609..e576fb8730c 100644 --- a/src/agents/pi-embedded-runner/model.startup-retry.test.ts +++ b/src/agents/pi-embedded-runner/model.startup-retry.test.ts @@ -41,6 +41,7 @@ vi.mock("../../plugins/provider-runtime.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + applyProviderResolvedModelCompatWithPlugins: () => undefined, clearProviderRuntimeHookCache: clearProviderRuntimeHookCacheMock, normalizeProviderResolvedModelWithPlugin: () => undefined, prepareProviderDynamicModel: (params: unknown) => prepareProviderDynamicModelMock(params), diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 41782e6a3ab..001a1508557 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -1044,6 +1044,7 @@ describe("resolveModel", () => { authStorage: { mocked: true } as never, modelRegistry: discoverModels({ mocked: true } as never, "/tmp/agent"), runtimeHooks: { + applyProviderResolvedModelCompatWithPlugins: () => undefined, buildProviderUnknownModelHintWithPlugin: () => undefined, prepareProviderDynamicModel: async () => {}, runProviderDynamicModel: () => undefined, diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index e7490fc3ed5..46846c73716 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -3,6 +3,7 @@ import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent"; import type { OpenClawConfig } from "../../config/config.js"; import type { ModelDefinitionConfig } from "../../config/types.js"; import { + applyProviderResolvedModelCompatWithPlugins, buildProviderUnknownModelHintWithPlugin, clearProviderRuntimeHookCache, normalizeProviderTransportWithPlugin, @@ -36,6 +37,9 @@ type InlineProviderConfig = { }; type ProviderRuntimeHooks = { + applyProviderResolvedModelCompatWithPlugins?: ( + params: Parameters[0], + ) => unknown; buildProviderUnknownModelHintWithPlugin: ( params: Parameters[0], ) => string | undefined; @@ -52,6 +56,7 @@ type ProviderRuntimeHooks = { }; const DEFAULT_PROVIDER_RUNTIME_HOOKS: ProviderRuntimeHooks = { + applyProviderResolvedModelCompatWithPlugins, buildProviderUnknownModelHintWithPlugin, prepareProviderDynamicModel, runProviderDynamicModel, @@ -121,9 +126,20 @@ function normalizeResolvedModel(params: { model: normalizedInputModel, }, }) as Model | undefined; + const compatNormalized = runtimeHooks.applyProviderResolvedModelCompatWithPlugins?.({ + provider: params.provider, + config: params.cfg, + context: { + config: params.cfg, + agentDir: params.agentDir, + provider: params.provider, + modelId: normalizedInputModel.id, + model: (pluginNormalized ?? normalizedInputModel) as never, + }, + }) as Model | undefined; return normalizeResolvedProviderModel({ provider: params.provider, - model: pluginNormalized ?? normalizedInputModel, + model: compatNormalized ?? pluginNormalized ?? normalizedInputModel, }); } diff --git a/src/agents/pi-model-discovery.auth.test.ts b/src/agents/pi-model-discovery.auth.test.ts index 1ec27d2a519..85c9a05192e 100644 --- a/src/agents/pi-model-discovery.auth.test.ts +++ b/src/agents/pi-model-discovery.auth.test.ts @@ -273,4 +273,111 @@ describe("discoverAuthStorage", () => { } }); }); + + it("normalizes discovered Mistral compat flags for custom Mistral-hosted providers", async () => { + await withAgentDir(async (agentDir) => { + saveAuthProfileStore( + { + version: 1, + profiles: { + "custom-api-mistral-ai:default": { + type: "api_key", + provider: "custom-api-mistral-ai", + key: "mistral-custom-key", + }, + }, + }, + agentDir, + ); + await writeModelsJson(agentDir, { + providers: { + "custom-api-mistral-ai": { + api: "openai-completions", + baseUrl: "https://api.mistral.ai/v1", + apiKey: "custom-api-mistral-ai", + models: [ + { + id: "mistral-small-latest", + name: "Mistral Small", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 16384, + }, + ], + }, + }, + }); + + const authStorage = discoverAuthStorage(agentDir); + const modelRegistry = discoverModels(authStorage, agentDir); + const model = modelRegistry.find("custom-api-mistral-ai", "mistral-small-latest") as { + compat?: { + supportsStore?: boolean; + supportsReasoningEffort?: boolean; + maxTokensField?: string; + }; + } | null; + + expect(model?.compat?.supportsStore).toBe(false); + expect(model?.compat?.supportsReasoningEffort).toBe(false); + expect(model?.compat?.maxTokensField).toBe("max_tokens"); + }); + }); + + it("normalizes discovered Mistral compat flags for OpenRouter Mistral model ids", async () => { + await withAgentDir(async (agentDir) => { + saveAuthProfileStore( + { + version: 1, + profiles: { + "openrouter:default": { + type: "api_key", + provider: "openrouter", + key: "sk-or-v1-runtime", + }, + }, + }, + agentDir, + ); + await writeModelsJson(agentDir, { + providers: { + openrouter: { + api: "openai-completions", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "OPENROUTER_API_KEY", + models: [ + { + id: "mistralai/mistral-small-3.2-24b-instruct", + name: "Mistral Small via OpenRouter", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 16384, + }, + ], + }, + }, + }); + + const authStorage = discoverAuthStorage(agentDir); + const modelRegistry = discoverModels(authStorage, agentDir); + const model = modelRegistry.find( + "openrouter", + "mistralai/mistral-small-3.2-24b-instruct", + ) as { + compat?: { + supportsStore?: boolean; + supportsReasoningEffort?: boolean; + maxTokensField?: string; + }; + } | null; + + expect(model?.compat?.supportsStore).toBe(false); + expect(model?.compat?.supportsReasoningEffort).toBe(false); + expect(model?.compat?.maxTokensField).toBe("max_tokens"); + }); + }); }); diff --git a/src/agents/pi-model-discovery.ts b/src/agents/pi-model-discovery.ts index 7b6c17b30ee..074853c20a4 100644 --- a/src/agents/pi-model-discovery.ts +++ b/src/agents/pi-model-discovery.ts @@ -7,7 +7,10 @@ import type { ModelRegistry as PiModelRegistry, } from "@mariozechner/pi-coding-agent"; import { normalizeModelCompat } from "../plugins/provider-model-compat.js"; -import { normalizeProviderResolvedModelWithPlugin } from "../plugins/provider-runtime.js"; +import { + applyProviderResolvedModelCompatWithPlugins, + normalizeProviderResolvedModelWithPlugin, +} from "../plugins/provider-runtime.js"; import type { ProviderRuntimeModel } from "../plugins/types.js"; import { ensureAuthProfileStore } from "./auth-profiles.js"; import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js"; @@ -75,7 +78,17 @@ function normalizeRegistryModel(value: T, agentDir: string): T { agentDir, }, }) ?? model; - return normalizeModelCompat(pluginNormalized as Model) as T; + const compatNormalized = + applyProviderResolvedModelCompatWithPlugins({ + provider: model.provider, + context: { + provider: model.provider, + modelId: model.id, + model: pluginNormalized, + agentDir, + }, + }) ?? pluginNormalized; + return normalizeModelCompat(compatNormalized as Model) as T; } class OpenClawModelRegistry extends PiModelRegistryClass { diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 7c5e5686738..fc15b5751ba 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -29,6 +29,7 @@ let applyProviderNativeStreamingUsageCompatWithPlugin: typeof import("./provider let formatProviderAuthProfileApiKeyWithPlugin: typeof import("./provider-runtime.js").formatProviderAuthProfileApiKeyWithPlugin; let normalizeProviderConfigWithPlugin: typeof import("./provider-runtime.js").normalizeProviderConfigWithPlugin; let normalizeProviderModelIdWithPlugin: typeof import("./provider-runtime.js").normalizeProviderModelIdWithPlugin; +let applyProviderResolvedModelCompatWithPlugins: typeof import("./provider-runtime.js").applyProviderResolvedModelCompatWithPlugins; let normalizeProviderTransportWithPlugin: typeof import("./provider-runtime.js").normalizeProviderTransportWithPlugin; let prepareProviderExtraParams: typeof import("./provider-runtime.js").prepareProviderExtraParams; let resolveProviderConfigApiKeyWithPlugin: typeof import("./provider-runtime.js").resolveProviderConfigApiKeyWithPlugin; @@ -211,6 +212,7 @@ describe("provider-runtime", () => { buildProviderMissingAuthMessageWithPlugin, buildProviderUnknownModelHintWithPlugin, applyProviderNativeStreamingUsageCompatWithPlugin, + applyProviderResolvedModelCompatWithPlugins, formatProviderAuthProfileApiKeyWithPlugin, normalizeProviderConfigWithPlugin, normalizeProviderModelIdWithPlugin, @@ -727,6 +729,13 @@ describe("provider-runtime", () => { api: "openai-codex-responses", }); + expect( + applyProviderResolvedModelCompatWithPlugins({ + provider: DEMO_PROVIDER_ID, + context: createDemoResolvedModelContext({}), + }), + ).toBeUndefined(); + expect( formatProviderAuthProfileApiKeyWithPlugin({ provider: DEMO_PROVIDER_ID, @@ -854,6 +863,53 @@ describe("provider-runtime", () => { ); }); + it("merges compat contributions from owner and foreign provider plugins", () => { + resolveOwningPluginIdsForProviderMock.mockReturnValue(["openrouter"]); + resolvePluginProvidersMock.mockImplementation((params) => { + const onlyPluginIds = params.onlyPluginIds ?? []; + const plugins: ProviderPlugin[] = [ + { + id: "openrouter", + label: "OpenRouter", + auth: [], + contributeResolvedModelCompat: () => ({ supportsStrictMode: true }), + }, + { + id: "mistral", + label: "Mistral", + auth: [], + contributeResolvedModelCompat: ({ modelId }) => + modelId.startsWith("mistralai/") ? { supportsStore: false } : undefined, + }, + ]; + return onlyPluginIds.length > 0 + ? plugins.filter((plugin) => onlyPluginIds.includes(plugin.id)) + : plugins; + }); + + expect( + applyProviderResolvedModelCompatWithPlugins({ + provider: "openrouter", + context: createDemoResolvedModelContext({ + provider: "openrouter", + modelId: "mistralai/mistral-small-3.2-24b-instruct", + model: { + ...MODEL, + provider: "openrouter", + id: "mistralai/mistral-small-3.2-24b-instruct", + compat: { supportsDeveloperRole: false }, + }, + }), + }), + ).toMatchObject({ + compat: { + supportsDeveloperRole: false, + supportsStrictMode: true, + supportsStore: false, + }, + }); + }); + it("resolves bundled catalog hooks through provider plugins", async () => { resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["openai"]); resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => { diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index e029b6be2a9..e8a43ba67f2 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -22,6 +22,7 @@ import type { ProviderFetchUsageSnapshotContext, ProviderNormalizeConfigContext, ProviderNormalizeModelIdContext, + ProviderNormalizeResolvedModelContext, ProviderNormalizeTransportContext, ProviderModernModelPolicyContext, ProviderPrepareExtraParamsContext, @@ -224,6 +225,79 @@ export function normalizeProviderResolvedModelWithPlugin(params: { ); } +function resolveProviderCompatHookPlugins(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ProviderPlugin[] { + const candidates = resolveProviderPluginsForHooks(params); + const owner = resolveProviderRuntimePlugin(params); + if (!owner) { + return candidates; + } + + const ordered = [owner, ...candidates]; + const seen = new Set(); + return ordered.filter((candidate) => { + const key = `${candidate.pluginId ?? ""}:${candidate.id}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +function applyCompatPatchToModel( + model: ProviderRuntimeModel, + patch: Record, +): ProviderRuntimeModel { + const compat = + model.compat && typeof model.compat === "object" + ? (model.compat as Record) + : undefined; + if (Object.entries(patch).every(([key, value]) => compat?.[key] === value)) { + return model; + } + return { + ...model, + compat: { + ...compat, + ...patch, + }, + }; +} + +export function applyProviderResolvedModelCompatWithPlugins(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderNormalizeResolvedModelContext; +}): ProviderRuntimeModel | undefined { + let nextModel = params.context.model; + let changed = false; + + for (const plugin of resolveProviderCompatHookPlugins(params)) { + const patch = plugin.contributeResolvedModelCompat?.({ + ...params.context, + model: nextModel, + }); + if (!patch || typeof patch !== "object") { + continue; + } + const patchedModel = applyCompatPatchToModel(nextModel, patch as Record); + if (patchedModel === nextModel) { + continue; + } + nextModel = patchedModel; + changed = true; + } + + return changed ? nextModel : undefined; +} + function resolveProviderHookPlugin(params: { provider: string; config?: OpenClawConfig; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index da606058f4c..bc2e0511b53 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -26,6 +26,7 @@ import type { ModelProviderAuthMode, ModelProviderConfig, } from "../config/types.js"; +import type { ModelCompatConfig } from "../config/types.models.js"; import type { OperatorScope } from "../gateway/method-scopes.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import type { InternalHookHandler } from "../hooks/internal-hooks.js"; @@ -885,6 +886,17 @@ export type ProviderPlugin = { normalizeResolvedModel?: ( ctx: ProviderNormalizeResolvedModelContext, ) => ProviderRuntimeModel | null | undefined; + /** + * Provider-owned compat contribution for resolved models outside direct + * provider ownership. + * + * Use this when a plugin can recognize its vendor's models behind another + * OpenAI-compatible transport (for example OpenRouter or a custom base URL) + * and needs to contribute compat flags without taking over the provider. + */ + contributeResolvedModelCompat?: ( + ctx: ProviderNormalizeResolvedModelContext, + ) => Partial | null | undefined; /** * Provider-owned model-id normalization. *