From 66825c09699a3fbff853a76dd63f72aa2ab3e29f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 3 Apr 2026 23:46:21 +0900 Subject: [PATCH] refactor(providers): centralize native provider detection (#60341) * refactor(providers): centralize native provider detection * fix(providers): preserve openrouter thinking format * fix(providers): preserve openrouter host thinking format --- extensions/mistral/api.test.ts | 50 ++++++++ extensions/mistral/index.ts | 34 ++++-- src/agents/openai-transport-stream.test.ts | 136 +++++++++++++++++++++ src/agents/openai-transport-stream.ts | 17 ++- src/agents/provider-attribution.test.ts | 21 ++++ src/agents/provider-attribution.ts | 5 + 6 files changed, 249 insertions(+), 14 deletions(-) diff --git a/extensions/mistral/api.test.ts b/extensions/mistral/api.test.ts index 4aca90c63e3..ce8c645f8b0 100644 --- a/extensions/mistral/api.test.ts +++ b/extensions/mistral/api.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { applyMistralModelCompat } from "./api.js"; +import { default as mistralPlugin } from "./index.js"; function supportsStore(model: { compat?: unknown }): boolean | undefined { return (model.compat as { supportsStore?: boolean } | undefined)?.supportsStore; @@ -48,4 +49,53 @@ describe("applyMistralModelCompat", () => { }; expect(applyMistralModelCompat(model)).toBe(model); }); + + it("contributes Mistral compat for native, provider-family, and hinted custom routes", () => { + const registerProvider = (mistralPlugin as { register?: (api: unknown) => void }).register; + let contributeResolvedModelCompat: + | ((params: { modelId: string; model: Record }) => unknown) + | undefined; + + registerProvider?.({ + registerProvider: (provider: { + contributeResolvedModelCompat?: typeof contributeResolvedModelCompat; + }) => { + contributeResolvedModelCompat = provider.contributeResolvedModelCompat; + }, + registerMediaUnderstandingProvider: () => {}, + }); + + expect( + contributeResolvedModelCompat?.({ + modelId: "mistral-large-latest", + model: { + provider: "mistral", + api: "openai-completions", + baseUrl: "https://proxy.example/v1", + }, + }), + ).toBeDefined(); + + expect( + contributeResolvedModelCompat?.({ + modelId: "custom-model", + model: { + provider: "custom-mistral-host", + api: "openai-completions", + baseUrl: "https://api.mistral.ai/v1", + }, + }), + ).toBeDefined(); + + expect( + contributeResolvedModelCompat?.({ + modelId: "mistralai/mistral-small-3.2", + model: { + provider: "openrouter", + api: "openai-completions", + baseUrl: "https://openrouter.ai/api/v1", + }, + }), + ).toBeDefined(); + }); }); diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index 1dc2536440f..acec875b306 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -1,4 +1,5 @@ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; +import { resolveProviderRequestCapabilities } from "openclaw/plugin-sdk/provider-http"; 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"; @@ -15,17 +16,6 @@ const MISTRAL_MODEL_HINTS = [ "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( @@ -39,12 +29,30 @@ function isMistralModelHint(modelId: string): boolean { function shouldContributeMistralCompat(params: { modelId: string; - model: { api?: unknown; baseUrl?: unknown }; + model: { api?: unknown; baseUrl?: unknown; provider?: unknown; compat?: unknown }; }): boolean { if (params.model.api !== "openai-completions") { return false; } - return isMistralBaseUrl(params.model.baseUrl) || isMistralModelHint(params.modelId); + + const capabilities = resolveProviderRequestCapabilities({ + provider: typeof params.model.provider === "string" ? params.model.provider : undefined, + api: "openai-completions", + baseUrl: typeof params.model.baseUrl === "string" ? params.model.baseUrl : undefined, + capability: "llm", + transport: "stream", + modelId: params.modelId, + compat: + params.model.compat && typeof params.model.compat === "object" + ? (params.model.compat as { supportsStore?: boolean }) + : undefined, + }); + + return ( + capabilities.knownProviderFamily === "mistral" || + capabilities.endpointClass === "mistral-public" || + isMistralModelHint(params.modelId) + ); } function buildMistralReplayPolicy() { diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index bf335445c01..67517c4799c 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -117,4 +117,140 @@ describe("openai transport stream", () => { totalTokens: 9, }); }); + + it("keeps OpenRouter thinking format for declared OpenRouter providers on custom proxy URLs", async () => { + const streamFn = buildTransportAwareSimpleStreamFn( + attachModelProviderRequestTransport( + { + id: "anthropic/claude-sonnet-4", + name: "Claude Sonnet 4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://proxy.example.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + { + proxy: { + mode: "explicit-proxy", + url: "http://proxy.internal:8443", + }, + }, + ), + ); + + expect(streamFn).toBeTypeOf("function"); + let capturedPayload: Record | undefined; + let resolveCaptured!: () => void; + const captured = new Promise((resolve) => { + resolveCaptured = resolve; + }); + + void streamFn!( + { + id: "anthropic/claude-sonnet-4", + name: "Claude Sonnet 4", + api: "openclaw-openai-completions-transport", + provider: "openrouter", + baseUrl: "https://proxy.example.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + } as Model<"openclaw-openai-completions-transport">, + { + systemPrompt: "system", + messages: [], + tools: [], + } as never, + { + reasoningEffort: "high", + onPayload: async (payload) => { + capturedPayload = payload as Record; + resolveCaptured(); + return payload; + }, + } as never, + ); + + await captured; + + expect(capturedPayload).toMatchObject({ + reasoning: { + effort: "high", + }, + }); + }); + + it("keeps OpenRouter thinking format for native OpenRouter hosts behind custom provider ids", async () => { + const streamFn = buildTransportAwareSimpleStreamFn( + attachModelProviderRequestTransport( + { + id: "anthropic/claude-sonnet-4", + name: "Claude Sonnet 4", + api: "openai-completions", + provider: "custom-openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + { + proxy: { + mode: "explicit-proxy", + url: "http://proxy.internal:8443", + }, + }, + ), + ); + + expect(streamFn).toBeTypeOf("function"); + let capturedPayload: Record | undefined; + let resolveCaptured!: () => void; + const captured = new Promise((resolve) => { + resolveCaptured = resolve; + }); + + void streamFn!( + { + id: "anthropic/claude-sonnet-4", + name: "Claude Sonnet 4", + api: "openclaw-openai-completions-transport", + provider: "custom-openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + } as Model<"openclaw-openai-completions-transport">, + { + systemPrompt: "system", + messages: [], + tools: [], + } as never, + { + reasoningEffort: "high", + onPayload: async (payload) => { + capturedPayload = payload as Record; + resolveCaptured(); + return payload; + }, + } as never, + ); + + await captured; + + expect(capturedPayload).toMatchObject({ + reasoning: { + effort: "high", + }, + }); + }); }); diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 775c45e7329..10814cfc0f9 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -13,6 +13,7 @@ import OpenAI, { AzureOpenAI } from "openai"; import type { ChatCompletionChunk } from "openai/resources/chat/completions.js"; import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; +import { resolveProviderRequestCapabilities } from "./provider-attribution.js"; import { buildProviderRequestDispatcherPolicy, getModelProviderRequestTransport, @@ -1317,6 +1318,18 @@ async function processOpenAICompletionsStream( function detectCompat(model: OpenAIModeModel) { const provider = model.provider; const baseUrl = model.baseUrl ?? ""; + const capabilities = resolveProviderRequestCapabilities({ + provider, + api: model.api, + baseUrl: model.baseUrl, + capability: "llm", + transport: "stream", + modelId: model.id, + compat: + model.compat && typeof model.compat === "object" + ? (model.compat as { supportsStore?: boolean }) + : undefined, + }); const isZai = provider === "zai" || baseUrl.includes("api.z.ai"); const isNonStandard = provider === "cerebras" || @@ -1353,7 +1366,9 @@ function detectCompat(model: OpenAIModeModel) { requiresThinkingAsText: false, thinkingFormat: isZai ? "zai" - : provider === "openrouter" || baseUrl.includes("openrouter.ai") + : provider === "openrouter" || + capabilities.endpointClass === "openrouter" || + capabilities.attributionProvider === "openrouter" ? "openrouter" : "openai", openRouterRouting: {}, diff --git a/src/agents/provider-attribution.test.ts b/src/agents/provider-attribution.test.ts index cce6fd8e118..408a0d5938f 100644 --- a/src/agents/provider-attribution.test.ts +++ b/src/agents/provider-attribution.test.ts @@ -206,6 +206,27 @@ describe("provider attribution", () => { }); }); + it("classifies native Mistral hosts centrally", () => { + expect(resolveProviderEndpoint("https://api.mistral.ai/v1")).toMatchObject({ + endpointClass: "mistral-public", + hostname: "api.mistral.ai", + }); + + expect( + resolveProviderRequestCapabilities({ + provider: "mistral", + api: "openai-completions", + baseUrl: "https://api.mistral.ai/v1", + capability: "llm", + transport: "stream", + }), + ).toMatchObject({ + endpointClass: "mistral-public", + isKnownNativeEndpoint: true, + knownProviderFamily: "mistral", + }); + }); + it("treats OpenRouter-hosted Responses routes as explicit proxy-like endpoints", () => { expect( resolveProviderRequestPolicy({ diff --git a/src/agents/provider-attribution.ts b/src/agents/provider-attribution.ts index 83f2b2ee41a..e78d7b49ff4 100644 --- a/src/agents/provider-attribution.ts +++ b/src/agents/provider-attribution.ts @@ -35,6 +35,7 @@ export type ProviderEndpointClass = | "default" | "anthropic-public" | "github-copilot-native" + | "mistral-public" | "moonshot-native" | "modelstudio-native" | "openai-public" @@ -202,6 +203,9 @@ export function resolveProviderEndpoint( if (host === "api.anthropic.com") { return { endpointClass: "anthropic-public", hostname: host }; } + if (host === "api.mistral.ai") { + return { endpointClass: "mistral-public", hostname: host }; + } if (host.endsWith(".githubcopilot.com")) { return { endpointClass: "github-copilot-native", hostname: host }; } @@ -498,6 +502,7 @@ export function resolveProviderRequestCapabilities( const isKnownNativeEndpoint = endpointClass === "anthropic-public" || endpointClass === "github-copilot-native" || + endpointClass === "mistral-public" || endpointClass === "moonshot-native" || endpointClass === "modelstudio-native" || endpointClass === "openai-public" ||