mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 05:12:15 +00:00
refactor(providers): centralize request capabilities (#59636)
* refactor(providers): centralize request capabilities * fix(providers): harden comparable base url parsing
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ModelDefinitionConfig> = [
|
||||
},
|
||||
];
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<typeof streamFn>[0],
|
||||
contextStub as Parameters<typeof streamFn>[1],
|
||||
);
|
||||
|
||||
const completed = new Promise<void>((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<string, unknown>;
|
||||
expect(sent.store).toBe(false);
|
||||
});
|
||||
|
||||
it("emits an AssistantMessage on response.completed", async () => {
|
||||
const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-2");
|
||||
const stream = streamFn(
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string> | 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<Api>): 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<Api>): model is Model<"anthropic-messages"> {
|
||||
return model.api === "anthropic-messages";
|
||||
}
|
||||
@@ -100,7 +92,15 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user