From c405bcfa98368ce78eeae9d9cf3b565dc9e7ab56 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 2 Apr 2026 20:26:22 +0900 Subject: [PATCH] refactor(providers): centralize request capabilities (#59636) * refactor(providers): centralize request capabilities * fix(providers): harden comparable base url parsing --- extensions/anthropic/stream-wrappers.ts | 34 ++--- extensions/modelstudio/models.ts | 30 ++-- extensions/moonshot/provider-catalog.ts | 25 ++-- src/agents/moonshot-provider-compat.ts | 5 - src/agents/openai-ws-stream.test.ts | 36 +++++ src/agents/openai-ws-stream.ts | 15 +- .../moonshot-stream-wrappers.ts | 17 +-- .../openai-stream-wrappers.ts | 54 ++----- src/agents/provider-attribution.test.ts | 117 +++++++++++++++ src/agents/provider-attribution.ts | 134 ++++++++++++++++++ src/plugin-sdk/provider-http.ts | 4 + src/plugins/provider-model-compat.ts | 20 +-- 12 files changed, 359 insertions(+), 132 deletions(-) delete mode 100644 src/agents/moonshot-provider-compat.ts diff --git a/extensions/anthropic/stream-wrappers.ts b/extensions/anthropic/stream-wrappers.ts index 5ca36182a2c..6808f18b252 100644 --- a/extensions/anthropic/stream-wrappers.ts +++ b/extensions/anthropic/stream-wrappers.ts @@ -1,6 +1,6 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import { streamSimple } from "@mariozechner/pi-ai"; -import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http"; +import { resolveProviderRequestCapabilities } from "openclaw/plugin-sdk/provider-http"; import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; @@ -52,14 +52,18 @@ function isAnthropicOAuthApiKey(apiKey: unknown): boolean { return typeof apiKey === "string" && apiKey.includes("sk-ant-oat"); } -function isAnthropicPublicApiBaseUrl(baseUrl: unknown): boolean { - if (baseUrl == null) { - return true; - } - if (typeof baseUrl !== "string" || !baseUrl.trim()) { - return true; - } - return resolveProviderEndpoint(baseUrl).endpointClass === "anthropic-public"; +function allowsAnthropicServiceTier(model: { + api?: unknown; + provider?: unknown; + baseUrl?: unknown; +}): boolean { + return resolveProviderRequestCapabilities({ + provider: typeof model.provider === "string" ? model.provider : undefined, + api: typeof model.api === "string" ? model.api : undefined, + baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined, + capability: "llm", + transport: "stream", + }).allowsAnthropicServiceTier; } function resolveAnthropicFastServiceTier(enabled: boolean): AnthropicServiceTier { @@ -157,11 +161,7 @@ export function createAnthropicFastModeWrapper( const underlying = baseStreamFn ?? streamSimple; const serviceTier = resolveAnthropicFastServiceTier(enabled); return (model, context, options) => { - if ( - model.api !== "anthropic-messages" || - model.provider !== "anthropic" || - !isAnthropicPublicApiBaseUrl(model.baseUrl) - ) { + if (!allowsAnthropicServiceTier(model)) { return underlying(model, context, options); } @@ -179,11 +179,7 @@ export function createAnthropicServiceTierWrapper( ): StreamFn { const underlying = baseStreamFn ?? streamSimple; return (model, context, options) => { - if ( - model.api !== "anthropic-messages" || - model.provider !== "anthropic" || - !isAnthropicPublicApiBaseUrl(model.baseUrl) - ) { + if (!allowsAnthropicServiceTier(model)) { return underlying(model, context, options); } diff --git a/extensions/modelstudio/models.ts b/extensions/modelstudio/models.ts index e742da097da..a9074b10a5c 100644 --- a/extensions/modelstudio/models.ts +++ b/extensions/modelstudio/models.ts @@ -1,3 +1,4 @@ +import { resolveProviderRequestCapabilities } from "openclaw/plugin-sdk/provider-http"; import type { ModelDefinitionConfig, ModelProviderConfig, @@ -94,29 +95,14 @@ export const MODELSTUDIO_MODEL_CATALOG: ReadonlyArray = [ }, ]; -function normalizeModelStudioBaseUrl(baseUrl: string | undefined): string { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return ""; - } - try { - const url = new URL(trimmed); - url.hash = ""; - url.search = ""; - return url.toString().replace(/\/+$/, "").toLowerCase(); - } catch { - return trimmed.replace(/\/+$/, "").toLowerCase(); - } -} - export function isNativeModelStudioBaseUrl(baseUrl: string | undefined): boolean { - const normalized = normalizeModelStudioBaseUrl(baseUrl); - return ( - normalized === MODELSTUDIO_BASE_URL || - normalized === MODELSTUDIO_CN_BASE_URL || - normalized === MODELSTUDIO_STANDARD_CN_BASE_URL || - normalized === MODELSTUDIO_STANDARD_GLOBAL_BASE_URL - ); + return resolveProviderRequestCapabilities({ + provider: "modelstudio", + api: "openai-completions", + baseUrl, + capability: "llm", + transport: "stream", + }).supportsNativeStreamingUsageCompat; } function withStreamingUsageCompat(provider: ModelProviderConfig): ModelProviderConfig { diff --git a/extensions/moonshot/provider-catalog.ts b/extensions/moonshot/provider-catalog.ts index 8cbbec462d4..b81cbff8d83 100644 --- a/extensions/moonshot/provider-catalog.ts +++ b/extensions/moonshot/provider-catalog.ts @@ -1,3 +1,4 @@ +import { resolveProviderRequestCapabilities } from "openclaw/plugin-sdk/provider-http"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; export const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; @@ -51,24 +52,14 @@ const MOONSHOT_MODEL_CATALOG = [ }, ] as const; -function normalizeMoonshotBaseUrl(baseUrl: string | undefined): string { - const trimmed = baseUrl?.trim(); - if (!trimmed) { - return ""; - } - try { - const url = new URL(trimmed); - url.hash = ""; - url.search = ""; - return url.toString().replace(/\/+$/, "").toLowerCase(); - } catch { - return trimmed.replace(/\/+$/, "").toLowerCase(); - } -} - export function isNativeMoonshotBaseUrl(baseUrl: string | undefined): boolean { - const normalized = normalizeMoonshotBaseUrl(baseUrl); - return normalized === MOONSHOT_BASE_URL || normalized === MOONSHOT_CN_BASE_URL; + return resolveProviderRequestCapabilities({ + provider: "moonshot", + api: "openai-completions", + baseUrl, + capability: "llm", + transport: "stream", + }).supportsNativeStreamingUsageCompat; } function withStreamingUsageCompat(provider: ModelProviderConfig): ModelProviderConfig { diff --git a/src/agents/moonshot-provider-compat.ts b/src/agents/moonshot-provider-compat.ts deleted file mode 100644 index 07498b07565..00000000000 --- a/src/agents/moonshot-provider-compat.ts +++ /dev/null @@ -1,5 +0,0 @@ -const MOONSHOT_THINKING_PAYLOAD_COMPAT_PROVIDERS = new Set(["moonshot", "kimi"]); - -export function usesMoonshotThinkingPayloadCompatStatic(provider?: string | null): boolean { - return provider != null && MOONSHOT_THINKING_PAYLOAD_COMPAT_PROVIDERS.has(provider); -} diff --git a/src/agents/openai-ws-stream.test.ts b/src/agents/openai-ws-stream.test.ts index 66dfcf74879..75f54954568 100644 --- a/src/agents/openai-ws-stream.test.ts +++ b/src/agents/openai-ws-stream.test.ts @@ -1267,6 +1267,42 @@ describe("createOpenAIWebSocketStreamFn", () => { expect(sent).not.toHaveProperty("store"); }); + it("keeps store=false for proxied openai-responses routes when store is still supported", async () => { + releaseWsSession("sess-store-proxy"); + const proxiedModel = { + ...modelStub, + baseUrl: "https://proxy.example.com/v1", + }; + const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-store-proxy"); + const stream = streamFn( + proxiedModel as Parameters[0], + contextStub as Parameters[1], + ); + + const completed = new Promise((res, rej) => { + queueMicrotask(async () => { + try { + await new Promise((r) => setImmediate(r)); + const manager = MockManager.lastInstance!; + manager.simulateEvent({ + type: "response.completed", + response: makeResponseObject("resp_store_proxy", "ok"), + }); + for await (const _ of await resolveStream(stream)) { + // consume + } + res(); + } catch (e) { + rej(e); + } + }); + }); + await completed; + + const sent = MockManager.lastInstance!.sentEvents[0] as Record; + expect(sent.store).toBe(false); + }); + it("emits an AssistantMessage on response.completed", async () => { const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-2"); const stream = streamFn( diff --git a/src/agents/openai-ws-stream.ts b/src/agents/openai-ws-stream.ts index 5d9b4aeec3b..98c2291e2ec 100644 --- a/src/agents/openai-ws-stream.ts +++ b/src/agents/openai-ws-stream.ts @@ -22,13 +22,13 @@ */ import type { StreamFn } from "@mariozechner/pi-agent-core"; -import * as piAi from "@mariozechner/pi-ai"; import type { AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, StopReason, } from "@mariozechner/pi-ai"; +import * as piAi from "@mariozechner/pi-ai"; import { OpenAIWebSocketManager, type FunctionToolDefinition, @@ -42,6 +42,7 @@ import { } from "./openai-ws-message-conversion.js"; import { log } from "./pi-embedded-runner/logger.js"; import { resolveOpenAITextVerbosity } from "./pi-embedded-runner/openai-stream-wrappers.js"; +import { resolveProviderRequestCapabilities } from "./provider-attribution.js"; import { buildAssistantMessageWithZeroUsage, buildStreamErrorAssistantMessage, @@ -485,13 +486,19 @@ export function createOpenAIWebSocketStreamFn( // Respect compat.supportsStore — providers like Gemini reject unknown // fields such as `store` with a 400 error. Fixes #39086. - const supportsStore = (model as { compat?: { supportsStore?: boolean } }).compat - ?.supportsStore; + const supportsResponsesStoreField = resolveProviderRequestCapabilities({ + provider: typeof model.provider === "string" ? model.provider : undefined, + api: typeof model.api === "string" ? model.api : undefined, + baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined, + compat: (model as { compat?: { supportsStore?: boolean } }).compat, + capability: "llm", + transport: "websocket", + }).supportsResponsesStoreField; const payload: Record = { type: "response.create", model: model.id, - ...(supportsStore !== false ? { store: false } : {}), + ...(supportsResponsesStoreField ? { store: false } : {}), input: turnInput.inputItems, instructions: context.systemPrompt ?? undefined, tools: tools.length > 0 ? tools : undefined, diff --git a/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts b/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts index 424af68f9bc..5a154bc8c77 100644 --- a/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts @@ -1,7 +1,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import { streamSimple } from "@mariozechner/pi-ai"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; -import { usesMoonshotThinkingPayloadCompatStatic } from "../moonshot-provider-compat.js"; +import { resolveProviderRequestCapabilities } from "../provider-attribution.js"; import { normalizeProviderId } from "../provider-id.js"; import { streamWithPayloadPatch } from "./stream-payload-utils.js"; @@ -27,16 +27,13 @@ export function shouldApplyMoonshotPayloadCompat(params: { modelId: string; }): boolean { const normalizedProvider = normalizeProviderId(params.provider); - const normalizedModelId = params.modelId.trim().toLowerCase(); - - if (usesMoonshotThinkingPayloadCompatStatic(normalizedProvider)) { - return true; - } - return ( - normalizedProvider === "ollama" && - normalizedModelId.startsWith("kimi-k") && - normalizedModelId.includes(":cloud") + resolveProviderRequestCapabilities({ + provider: normalizedProvider, + modelId: params.modelId, + capability: "llm", + transport: "stream", + }).compatibilityFamily === "moonshot" ); } diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts index e8875e839e1..6e092f2fdb2 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts @@ -6,7 +6,7 @@ import { patchCodexNativeWebSearchPayload, resolveCodexNativeSearchActivation, } from "../codex-native-web-search.js"; -import { resolveProviderRequestPolicy } from "../provider-attribution.js"; +import { resolveProviderRequestCapabilities } from "../provider-attribution.js"; import { resolveProviderRequestHeaders } from "../provider-request-config.js"; import { log } from "./logger.js"; import { streamWithPayloadPatch } from "./stream-payload-utils.js"; @@ -15,7 +15,6 @@ type OpenAIServiceTier = "auto" | "default" | "flex" | "priority"; type OpenAITextVerbosity = "low" | "medium" | "high"; const OPENAI_RESPONSES_APIS = new Set(["openai-responses", "azure-openai-responses"]); -const OPENAI_RESPONSES_PROVIDERS = new Set(["openai", "azure-openai", "azure-openai-responses"]); const OPENAI_REASONING_COMPAT_PROVIDERS = new Set([ "openai", "openai-codex", @@ -23,15 +22,17 @@ const OPENAI_REASONING_COMPAT_PROVIDERS = new Set([ "azure-openai-responses", ]); -function resolveOpenAIRequestPolicy(model: { +function resolveOpenAIRequestCapabilities(model: { api?: unknown; provider?: unknown; baseUrl?: unknown; + compat?: { supportsStore?: boolean }; }) { - return resolveProviderRequestPolicy({ + return resolveProviderRequestCapabilities({ provider: typeof model.provider === "string" ? model.provider : undefined, api: typeof model.api === "string" ? model.api : undefined, baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined, + compat: model.compat, capability: "llm", transport: "stream", }); @@ -42,7 +43,7 @@ function shouldApplyOpenAIAttributionHeaders(model: { provider?: unknown; baseUrl?: unknown; }): "openai" | "openai-codex" | undefined { - const attributionProvider = resolveOpenAIRequestPolicy(model).attributionProvider; + const attributionProvider = resolveOpenAIRequestCapabilities(model).attributionProvider; return attributionProvider === "openai" || attributionProvider === "openai-codex" ? attributionProvider : undefined; @@ -53,22 +54,7 @@ function shouldApplyOpenAIServiceTier(model: { provider?: unknown; baseUrl?: unknown; }): boolean { - const policy = resolveOpenAIRequestPolicy(model); - if ( - model.provider === "openai" && - model.api === "openai-responses" && - policy.endpointClass === "openai-public" - ) { - return true; - } - if ( - model.provider === "openai-codex" && - (model.api === "openai-codex-responses" || model.api === "openai-responses") && - policy.endpointClass === "openai-codex" - ) { - return true; - } - return false; + return resolveOpenAIRequestCapabilities(model).allowsOpenAIServiceTier; } function shouldForceResponsesStore(model: { @@ -77,19 +63,7 @@ function shouldForceResponsesStore(model: { baseUrl?: unknown; compat?: { supportsStore?: boolean }; }): boolean { - if (model.compat?.supportsStore === false) { - return false; - } - if (typeof model.api !== "string" || typeof model.provider !== "string") { - return false; - } - if (!OPENAI_RESPONSES_APIS.has(model.api)) { - return false; - } - if (!OPENAI_RESPONSES_PROVIDERS.has(model.provider)) { - return false; - } - return resolveOpenAIRequestPolicy(model).usesKnownNativeOpenAIEndpoint; + return resolveOpenAIRequestCapabilities(model).allowsResponsesStore; } function parsePositiveInteger(value: unknown): number | undefined { @@ -149,17 +123,7 @@ function shouldStripResponsesStore( } function shouldStripResponsesPromptCache(model: { api?: unknown; baseUrl?: unknown }): boolean { - if (typeof model.api !== "string" || !OPENAI_RESPONSES_APIS.has(model.api)) { - return false; - } - // Missing baseUrl means pi-ai will use the default OpenAI endpoint, so keep - // prompt cache fields for that direct path. - return resolveProviderRequestPolicy({ - baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined, - api: typeof model.api === "string" ? model.api : undefined, - transport: "stream", - capability: "llm", - }).usesExplicitProxyLikeEndpoint; + return resolveOpenAIRequestCapabilities(model).shouldStripResponsesPromptCache; } function shouldApplyOpenAIReasoningCompatibility(model: { diff --git a/src/agents/provider-attribution.test.ts b/src/agents/provider-attribution.test.ts index 4018c991f3a..6a40b0d2ce4 100644 --- a/src/agents/provider-attribution.test.ts +++ b/src/agents/provider-attribution.test.ts @@ -6,6 +6,7 @@ import { resolveProviderAttributionPolicy, resolveProviderEndpoint, resolveProviderRequestAttributionHeaders, + resolveProviderRequestCapabilities, resolveProviderRequestPolicy, } from "./provider-attribution.js"; @@ -315,6 +316,30 @@ describe("provider attribution", () => { }); }); + it("classifies native Moonshot and ModelStudio endpoints separately from custom hosts", () => { + expect(resolveProviderEndpoint("https://api.moonshot.ai/v1")).toMatchObject({ + endpointClass: "moonshot-native", + hostname: "api.moonshot.ai", + }); + + expect(resolveProviderEndpoint("https://api.moonshot.cn/v1")).toMatchObject({ + endpointClass: "moonshot-native", + hostname: "api.moonshot.cn", + }); + + expect( + resolveProviderEndpoint("https://dashscope-intl.aliyuncs.com/compatible-mode/v1"), + ).toMatchObject({ + endpointClass: "modelstudio-native", + hostname: "dashscope-intl.aliyuncs.com", + }); + + expect(resolveProviderEndpoint("https://proxy.example.com/v1")).toMatchObject({ + endpointClass: "custom", + hostname: "proxy.example.com", + }); + }); + it("does not classify malformed or embedded Google host strings as native endpoints", () => { expect(resolveProviderEndpoint("proxy/generativelanguage.googleapis.com")).toMatchObject({ endpointClass: "custom", @@ -359,6 +384,12 @@ describe("provider attribution", () => { }); }); + it("ignores non-http schemes when normalizing native comparable base URLs", () => { + expect(resolveProviderEndpoint("javascript:alert(1)")).toMatchObject({ + endpointClass: "invalid", + }); + }); + it("requires the dedicated OpenAI audio transcription API for audio attribution", () => { expect( resolveProviderRequestPolicy({ @@ -399,4 +430,90 @@ describe("provider attribution", () => { allowsHiddenAttribution: false, }); }); + + it("resolves centralized request capabilities for native and proxied routes", () => { + expect( + resolveProviderRequestCapabilities({ + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + capability: "llm", + transport: "stream", + }), + ).toMatchObject({ + endpointClass: "openai-public", + allowsOpenAIServiceTier: true, + allowsResponsesStore: true, + supportsResponsesStoreField: true, + shouldStripResponsesPromptCache: false, + }); + + expect( + resolveProviderRequestCapabilities({ + provider: "anthropic", + api: "anthropic-messages", + capability: "llm", + transport: "stream", + }), + ).toMatchObject({ + endpointClass: "default", + allowsAnthropicServiceTier: true, + }); + + expect( + resolveProviderRequestCapabilities({ + provider: "custom-proxy", + api: "openai-responses", + baseUrl: "https://proxy.example.com/v1", + capability: "llm", + transport: "stream", + }), + ).toMatchObject({ + endpointClass: "custom", + allowsOpenAIServiceTier: false, + allowsResponsesStore: false, + supportsResponsesStoreField: true, + shouldStripResponsesPromptCache: true, + }); + }); + + it("resolves shared compat families and native streaming-usage gates", () => { + expect( + resolveProviderRequestCapabilities({ + provider: "moonshot", + api: "openai-completions", + baseUrl: "https://api.moonshot.ai/v1", + capability: "llm", + transport: "stream", + }), + ).toMatchObject({ + endpointClass: "moonshot-native", + supportsNativeStreamingUsageCompat: true, + compatibilityFamily: "moonshot", + }); + + expect( + resolveProviderRequestCapabilities({ + provider: "modelstudio", + api: "openai-completions", + baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + capability: "llm", + transport: "stream", + }), + ).toMatchObject({ + endpointClass: "modelstudio-native", + supportsNativeStreamingUsageCompat: true, + }); + + expect( + resolveProviderRequestCapabilities({ + provider: "ollama", + modelId: "kimi-k2.5:cloud", + capability: "llm", + transport: "stream", + }), + ).toMatchObject({ + compatibilityFamily: "moonshot", + }); + }); }); diff --git a/src/agents/provider-attribution.ts b/src/agents/provider-attribution.ts index f5c098d991b..bef9c899ceb 100644 --- a/src/agents/provider-attribution.ts +++ b/src/agents/provider-attribution.ts @@ -34,6 +34,8 @@ export type ProviderRequestCapability = "llm" | "audio" | "image" | "video" | "o export type ProviderEndpointClass = | "default" | "anthropic-public" + | "moonshot-native" + | "modelstudio-native" | "openai-public" | "openai-codex" | "azure-openai" @@ -73,10 +75,43 @@ export type ProviderRequestPolicyResolution = { usesExplicitProxyLikeEndpoint: boolean; }; +export type ProviderRequestCapabilitiesInput = ProviderRequestPolicyInput & { + modelId?: string | null; + compat?: { + supportsStore?: boolean; + } | null; +}; + +export type ProviderRequestCompatibilityFamily = "moonshot"; + +export type ProviderRequestCapabilities = ProviderRequestPolicyResolution & { + isKnownNativeEndpoint: boolean; + allowsOpenAIServiceTier: boolean; + allowsAnthropicServiceTier: boolean; + supportsResponsesStoreField: boolean; + allowsResponsesStore: boolean; + shouldStripResponsesPromptCache: boolean; + supportsNativeStreamingUsageCompat: boolean; + compatibilityFamily?: ProviderRequestCompatibilityFamily; +}; + const OPENCLAW_ATTRIBUTION_PRODUCT = "OpenClaw"; const OPENCLAW_ATTRIBUTION_ORIGINATOR = "openclaw"; const LOCAL_ENDPOINT_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); +const MOONSHOT_NATIVE_BASE_URLS = new Set([ + "https://api.moonshot.ai/v1", + "https://api.moonshot.cn/v1", +]); +const MODELSTUDIO_NATIVE_BASE_URLS = new Set([ + "https://coding-intl.dashscope.aliyuncs.com/v1", + "https://coding.dashscope.aliyuncs.com/v1", + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", +]); +const OPENAI_RESPONSES_APIS = new Set(["openai-responses", "azure-openai-responses"]); +const OPENAI_RESPONSES_PROVIDERS = new Set(["openai", "azure-openai", "azure-openai-responses"]); +const MOONSHOT_COMPAT_PROVIDERS = new Set(["moonshot", "kimi"]); function formatOpenClawUserAgent(version: string): string { return `${OPENCLAW_ATTRIBUTION_ORIGINATOR}/${version}`; @@ -110,6 +145,29 @@ function resolveUrlHostname(value: unknown): string | undefined { return tryParseHostname(`https://${trimmed}`); } +function normalizeComparableBaseUrl(value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + const parsedValue = + tryParseHostname(trimmed) || !isSchemelessHostnameCandidate(trimmed) + ? trimmed + : `https://${trimmed}`; + try { + const url = new URL(parsedValue); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return undefined; + } + url.hash = ""; + url.search = ""; + return url.toString().replace(/\/+$/, "").toLowerCase(); + } catch { + return undefined; + } +} + function isLocalEndpointHost(host: string): boolean { return ( LOCAL_ENDPOINT_HOSTS.has(host) || @@ -130,6 +188,13 @@ export function resolveProviderEndpoint( if (!host) { return { endpointClass: "invalid" }; } + const normalizedBaseUrl = normalizeComparableBaseUrl(baseUrl); + if (normalizedBaseUrl && MOONSHOT_NATIVE_BASE_URLS.has(normalizedBaseUrl)) { + return { endpointClass: "moonshot-native", hostname: host }; + } + if (normalizedBaseUrl && MODELSTUDIO_NATIVE_BASE_URLS.has(normalizedBaseUrl)) { + return { endpointClass: "modelstudio-native", hostname: host }; + } if (host === "api.openai.com") { return { endpointClass: "openai-public", hostname: host }; } @@ -182,6 +247,12 @@ function resolveKnownProviderFamily(provider: string | undefined): string { return "anthropic"; case "google": return "google"; + case "moonshot": + case "kimi": + return "moonshot"; + case "modelstudio": + case "dashscope": + return "modelstudio"; case "github-copilot": return "github-copilot"; case "groq": @@ -411,3 +482,66 @@ export function resolveProviderRequestAttributionHeaders( ): Record | undefined { return resolveProviderRequestPolicy(input, env).attributionHeaders; } + +export function resolveProviderRequestCapabilities( + input: ProviderRequestCapabilitiesInput, + env: RuntimeVersionEnv = process.env as RuntimeVersionEnv, +): ProviderRequestCapabilities { + const policy = resolveProviderRequestPolicy(input, env); + const provider = policy.provider; + const api = input.api?.trim().toLowerCase(); + const normalizedModelId = input.modelId?.trim().toLowerCase(); + const endpointClass = policy.endpointClass; + const isKnownNativeEndpoint = + endpointClass === "anthropic-public" || + endpointClass === "moonshot-native" || + endpointClass === "modelstudio-native" || + endpointClass === "openai-public" || + endpointClass === "openai-codex" || + endpointClass === "azure-openai" || + endpointClass === "openrouter" || + endpointClass === "google-generative-ai" || + endpointClass === "google-vertex"; + + let compatibilityFamily: ProviderRequestCompatibilityFamily | undefined; + if (provider && MOONSHOT_COMPAT_PROVIDERS.has(provider)) { + compatibilityFamily = "moonshot"; + } else if ( + provider === "ollama" && + normalizedModelId?.startsWith("kimi-k") && + normalizedModelId.includes(":cloud") + ) { + compatibilityFamily = "moonshot"; + } + + return { + ...policy, + isKnownNativeEndpoint, + allowsOpenAIServiceTier: + (provider === "openai" && api === "openai-responses" && endpointClass === "openai-public") || + (provider === "openai-codex" && + (api === "openai-codex-responses" || api === "openai-responses") && + endpointClass === "openai-codex"), + allowsAnthropicServiceTier: + provider === "anthropic" && + api === "anthropic-messages" && + (endpointClass === "default" || endpointClass === "anthropic-public"), + // This is intentionally the gate for emitting `store: false` on Responses + // transports, not just a statement about vendor support in the abstract. + supportsResponsesStoreField: + input.compat?.supportsStore !== false && api !== undefined && OPENAI_RESPONSES_APIS.has(api), + allowsResponsesStore: + input.compat?.supportsStore !== false && + provider !== undefined && + api !== undefined && + OPENAI_RESPONSES_APIS.has(api) && + OPENAI_RESPONSES_PROVIDERS.has(provider) && + policy.usesKnownNativeOpenAIEndpoint, + shouldStripResponsesPromptCache: + api !== undefined && OPENAI_RESPONSES_APIS.has(api) && policy.usesExplicitProxyLikeEndpoint, + supportsNativeStreamingUsageCompat: + (provider === "moonshot" && endpointClass === "moonshot-native") || + (provider === "modelstudio" && endpointClass === "modelstudio-native"), + compatibilityFamily, + }; +} diff --git a/src/plugin-sdk/provider-http.ts b/src/plugin-sdk/provider-http.ts index 7a237e2ec3d..f30a445696d 100644 --- a/src/plugin-sdk/provider-http.ts +++ b/src/plugin-sdk/provider-http.ts @@ -13,6 +13,9 @@ export { } from "../media-understanding/shared.js"; export type { ProviderAttributionPolicy, + ProviderRequestCapabilities, + ProviderRequestCapabilitiesInput, + ProviderRequestCompatibilityFamily, ProviderEndpointClass, ProviderEndpointResolution, ProviderRequestCapability, @@ -22,5 +25,6 @@ export type { } from "../agents/provider-attribution.js"; export { resolveProviderEndpoint, + resolveProviderRequestCapabilities, resolveProviderRequestPolicy, } from "../agents/provider-attribution.js"; diff --git a/src/plugins/provider-model-compat.ts b/src/plugins/provider-model-compat.ts index 5a189364aa5..37e1a51ef92 100644 --- a/src/plugins/provider-model-compat.ts +++ b/src/plugins/provider-model-compat.ts @@ -1,4 +1,5 @@ import type { Api, Model } from "@mariozechner/pi-ai"; +import { resolveProviderRequestCapabilities } from "../agents/provider-attribution.js"; import type { ModelCompatConfig } from "../config/types.models.js"; function extractModelCompat( @@ -68,15 +69,6 @@ function isOpenAiCompletionsModel(model: Model): model is Model<"openai-com return model.api === "openai-completions"; } -function isOpenAINativeEndpoint(baseUrl: string): boolean { - try { - const host = new URL(baseUrl).hostname.toLowerCase(); - return host === "api.openai.com"; - } catch { - return false; - } -} - function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> { return model.api === "anthropic-messages"; } @@ -100,7 +92,15 @@ export function normalizeModelCompat(model: Model): Model { } const compat = model.compat ?? undefined; - const needsForce = baseUrl ? !isOpenAINativeEndpoint(baseUrl) : false; + const needsForce = baseUrl + ? resolveProviderRequestCapabilities({ + provider: typeof model.provider === "string" ? model.provider : undefined, + api: model.api, + baseUrl, + capability: "llm", + transport: "stream", + }).endpointClass !== "openai-public" + : false; if (!needsForce) { return model; }