mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(ai): queue custom transport registrations until the host is configured
This commit is contained in:
@@ -962,7 +962,6 @@ src/plugin-sdk/approval-native-helpers.ts
|
||||
src/plugin-sdk/core.ts
|
||||
src/plugin-sdk/provider-auth.test.ts
|
||||
src/plugin-sdk/provider-stream-shared.test.ts
|
||||
src/plugin-sdk/provider-stream-shared.ts
|
||||
src/plugin-sdk/test-helpers/plugin-runtime-mock.ts
|
||||
src/plugin-sdk/test-helpers/provider-discovery-contract.ts
|
||||
src/plugin-sdk/test-helpers/provider-runtime-contract.ts
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createAssistantMessageEventStream } from "@openclaw/llm-core";
|
||||
import type { Api, Model, StreamFn } from "@openclaw/llm-core";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { createApiRegistry, type ApiRegistry } from "./api-registry.js";
|
||||
|
||||
const CUSTOM_API = "openclaw-openai-responses-transport";
|
||||
|
||||
function registerCustomApi(registry: ApiRegistry, api: Api, _streamFn: StreamFn): boolean {
|
||||
if (registry.getApiProvider(api)) {
|
||||
return false;
|
||||
}
|
||||
const stream = () => createAssistantMessageEventStream();
|
||||
registry.registerApiProvider({ api, stream, streamSimple: stream });
|
||||
return true;
|
||||
}
|
||||
|
||||
describe("AI transport host configuration", () => {
|
||||
let initialHost: import("./host.js").AiTransportHost | undefined;
|
||||
|
||||
afterAll(async () => {
|
||||
if (!initialHost) {
|
||||
return;
|
||||
}
|
||||
const { configureAiTransportHost } = await import("./host.js");
|
||||
configureAiTransportHost(initialHost);
|
||||
});
|
||||
|
||||
it("replays custom API registration when transports load before the concrete host", async () => {
|
||||
const { prepareModelForSimpleCompletion } = await import("./transports.js");
|
||||
const { configureAiTransportHost, getAiTransportHost } = await import("./host.js");
|
||||
initialHost = getAiTransportHost();
|
||||
configureAiTransportHost({});
|
||||
|
||||
const registry = createApiRegistry();
|
||||
const sourceModel: Model<"openai-chatgpt-responses"> = {
|
||||
id: "gpt-test",
|
||||
name: "GPT Test",
|
||||
api: "openai-chatgpt-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8_192,
|
||||
maxTokens: 1_024,
|
||||
};
|
||||
const preparedModel = prepareModelForSimpleCompletion({
|
||||
apiRegistry: registry,
|
||||
model: sourceModel,
|
||||
});
|
||||
|
||||
expect(preparedModel.api).toBe(CUSTOM_API);
|
||||
expect(registry.getApiProvider(CUSTOM_API)).toBeUndefined();
|
||||
|
||||
const registrar = vi.fn(registerCustomApi);
|
||||
configureAiTransportHost({ registerCustomApi: registrar });
|
||||
configureAiTransportHost({ registerCustomApi: registrar });
|
||||
|
||||
const provider = registry.getApiProvider(CUSTOM_API);
|
||||
expect(provider).toBeDefined();
|
||||
expect(registrar).toHaveBeenCalledOnce();
|
||||
expect(provider).toMatchObject({
|
||||
api: CUSTOM_API,
|
||||
stream: expect.any(Function),
|
||||
streamSimple: expect.any(Function),
|
||||
});
|
||||
expect(provider?.streamSimple(preparedModel, { messages: [] })).toHaveProperty("result");
|
||||
});
|
||||
});
|
||||
+49
-1
@@ -161,6 +161,31 @@ export interface AiTransportHost {
|
||||
logWarn(subsystem: string, message: string, data?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
const MAX_PENDING_CUSTOM_API_REGISTRATIONS = 32;
|
||||
|
||||
type PendingCustomApiRegistration = {
|
||||
registry: ApiRegistry;
|
||||
api: Api;
|
||||
streamFn: StreamFn;
|
||||
};
|
||||
|
||||
const pendingCustomApiRegistrations: PendingCustomApiRegistration[] = [];
|
||||
|
||||
function queueCustomApiRegistration(registry: ApiRegistry, api: Api, streamFn: StreamFn): boolean {
|
||||
const existing = pendingCustomApiRegistrations.find(
|
||||
(registration) => registration.registry === registry && registration.api === api,
|
||||
);
|
||||
if (existing) {
|
||||
existing.streamFn = streamFn;
|
||||
return true;
|
||||
}
|
||||
if (pendingCustomApiRegistrations.length >= MAX_PENDING_CUSTOM_API_REGISTRATIONS) {
|
||||
throw new Error("Too many custom transport APIs were registered before host configuration");
|
||||
}
|
||||
pendingCustomApiRegistrations.push({ registry, api, streamFn });
|
||||
return true;
|
||||
}
|
||||
|
||||
const inertAiTransportHost: AiTransportHost = {
|
||||
buildModelFetch: () => undefined,
|
||||
resolveSecretSentinel: (value) => value,
|
||||
@@ -193,7 +218,7 @@ const inertAiTransportHost: AiTransportHost = {
|
||||
resolveModelRequestTimeoutMs: () => undefined,
|
||||
requiresManagedTransport: () => false,
|
||||
transformTransportMessages: (messages) => messages,
|
||||
registerCustomApi: () => false,
|
||||
registerCustomApi: queueCustomApiRegistration,
|
||||
prepareGoogleSimpleCompletionModel: (_registry, model) => model,
|
||||
logDebug: () => {},
|
||||
logInfo: () => {},
|
||||
@@ -209,6 +234,29 @@ export function configureAiTransportHost(host: Partial<AiTransportHost>): void {
|
||||
...host,
|
||||
plugin: { ...inertAiTransportHost.plugin, ...host.plugin },
|
||||
};
|
||||
const transportHost = activeAiTransportHost;
|
||||
if (
|
||||
transportHost.registerCustomApi === inertAiTransportHost.registerCustomApi ||
|
||||
pendingCustomApiRegistrations.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Transport modules may register before host wiring. Drain once after a concrete
|
||||
// registrar installs so module caching cannot permanently lose those registrations.
|
||||
const pending = pendingCustomApiRegistrations.splice(0);
|
||||
for (const [index, registration] of pending.entries()) {
|
||||
try {
|
||||
transportHost.registerCustomApi(
|
||||
registration.registry,
|
||||
registration.api,
|
||||
registration.streamFn,
|
||||
);
|
||||
} catch (error) {
|
||||
pendingCustomApiRegistrations.unshift(...pending.slice(index));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the active transport host (inert defaults unless configured). */
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Provider transport implementations and transport-specific compatibility helpers. */
|
||||
export * from "./transports/anthropic-payload-policy.js";
|
||||
export * from "./transports/anthropic-transport-stream.js";
|
||||
export * from "./transports/deepseek-text-filter.js";
|
||||
export * from "./transports/json-unsafe-integers.js";
|
||||
export * from "./transports/model-max-tokens-params.js";
|
||||
export * from "./transports/model-transport-debug.js";
|
||||
export * from "./transports/model-transport-url.js";
|
||||
export * from "./transports/openai-compatible-conversation-turn.js";
|
||||
export * from "./transports/openai-completions-compat.js";
|
||||
export * from "./transports/openai-completions-string-content.js";
|
||||
export * from "./transports/openai-completions-transport.js";
|
||||
export * from "./transports/openai-reasoning-compat.js";
|
||||
export * from "./transports/openai-responses-payload-policy.js";
|
||||
export * from "./transports/openai-responses-replay.js";
|
||||
export * from "./transports/openai-responses-transport.js";
|
||||
export * from "./transports/openai-transport-params.js";
|
||||
export * from "./transports/openai-transport-shared.js";
|
||||
export * from "./transports/provider-transport-stream.js";
|
||||
export * from "./transports/responses-image-payload-sanitizer.js";
|
||||
export * from "./transports/simple-completion-transport.js";
|
||||
export * from "./transports/transport-stream-shared.js";
|
||||
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
splitSystemPromptCacheBoundary,
|
||||
stripSystemPromptCacheBoundary,
|
||||
} from "../internal/shared.js";
|
||||
/**
|
||||
* Anthropic-family request payload policy helpers.
|
||||
* Applies service-tier and cache-control markers only when provider endpoint
|
||||
* capabilities allow them.
|
||||
*/
|
||||
import { resolveProviderRequestCapabilities } from "./host-policy.js";
|
||||
|
||||
/** @deprecated Anthropic-family provider payload helper; do not use from third-party plugins. */
|
||||
type AnthropicServiceTier = "auto" | "standard_only";
|
||||
|
||||
/** @deprecated Anthropic-family provider payload helper; do not use from third-party plugins. */
|
||||
type AnthropicEphemeralCacheControl = {
|
||||
type: "ephemeral";
|
||||
ttl?: "1h";
|
||||
};
|
||||
|
||||
type AnthropicPayloadPolicyInput = {
|
||||
api?: string;
|
||||
baseUrl?: string;
|
||||
cacheRetention?: "short" | "long" | "none";
|
||||
enableCacheControl?: boolean;
|
||||
provider?: string;
|
||||
serviceTier?: AnthropicServiceTier;
|
||||
};
|
||||
|
||||
const ANTHROPIC_CACHE_CONTROL_LIMIT = 4;
|
||||
|
||||
/** @deprecated Anthropic-family provider payload helper; do not use from third-party plugins. */
|
||||
type AnthropicPayloadPolicy = {
|
||||
allowsServiceTier: boolean;
|
||||
cacheControl: AnthropicEphemeralCacheControl | undefined;
|
||||
serviceTier: AnthropicServiceTier | undefined;
|
||||
};
|
||||
|
||||
function resolveBaseUrlHostname(baseUrl: string): string | undefined {
|
||||
try {
|
||||
return new URL(baseUrl).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isLongTtlEligibleEndpoint(baseUrl: string | undefined): boolean {
|
||||
if (typeof baseUrl !== "string") {
|
||||
return false;
|
||||
}
|
||||
const hostname = resolveBaseUrlHostname(baseUrl);
|
||||
if (!hostname) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
hostname === "api.anthropic.com" ||
|
||||
hostname === "aiplatform.googleapis.com" ||
|
||||
hostname.endsWith("-aiplatform.googleapis.com")
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve Anthropic cache-control marker retention for a request endpoint. */
|
||||
export function resolveAnthropicEphemeralCacheControl(
|
||||
baseUrl: string | undefined,
|
||||
cacheRetention: AnthropicPayloadPolicyInput["cacheRetention"],
|
||||
): AnthropicEphemeralCacheControl | undefined {
|
||||
const retention =
|
||||
cacheRetention ?? (process.env.OPENCLAW_CACHE_RETENTION === "long" ? "long" : "short");
|
||||
if (retention === "none") {
|
||||
return undefined;
|
||||
}
|
||||
// Trust explicit long-retention opt-ins for Anthropic-compatible custom providers.
|
||||
// Keep hostname gating for implicit/env-driven long retention so defaults stay conservative.
|
||||
const ttl =
|
||||
retention === "long" && (cacheRetention === "long" || isLongTtlEligibleEndpoint(baseUrl))
|
||||
? "1h"
|
||||
: undefined;
|
||||
return { type: "ephemeral", ...(ttl ? { ttl } : {}) };
|
||||
}
|
||||
|
||||
function applyAnthropicCacheControlToSystem(
|
||||
system: unknown,
|
||||
cacheControl: AnthropicEphemeralCacheControl,
|
||||
): void {
|
||||
if (!Array.isArray(system)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedBlocks: Array<unknown> = [];
|
||||
for (const block of system) {
|
||||
if (!block || typeof block !== "object") {
|
||||
normalizedBlocks.push(block);
|
||||
continue;
|
||||
}
|
||||
const record = block as Record<string, unknown>;
|
||||
if (record.type !== "text" || typeof record.text !== "string") {
|
||||
normalizedBlocks.push(block);
|
||||
continue;
|
||||
}
|
||||
const split = splitSystemPromptCacheBoundary(record.text);
|
||||
if (!split) {
|
||||
if (record.cache_control === undefined) {
|
||||
record.cache_control = cacheControl;
|
||||
}
|
||||
normalizedBlocks.push(record);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { cache_control: existingCacheControl, ...rest } = record;
|
||||
if (split.stablePrefix) {
|
||||
normalizedBlocks.push({
|
||||
...rest,
|
||||
text: split.stablePrefix,
|
||||
cache_control: existingCacheControl ?? cacheControl,
|
||||
});
|
||||
}
|
||||
if (split.dynamicSuffix) {
|
||||
normalizedBlocks.push({
|
||||
...rest,
|
||||
text: split.dynamicSuffix,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
system.splice(0, system.length, ...normalizedBlocks);
|
||||
}
|
||||
|
||||
function stripAnthropicSystemPromptBoundary(system: unknown): void {
|
||||
if (!Array.isArray(system)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const block of system) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = block as Record<string, unknown>;
|
||||
if (record.type === "text" && typeof record.text === "string") {
|
||||
record.text = stripSystemPromptCacheBoundary(record.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyAnthropicCacheControlToMessages(
|
||||
messages: unknown,
|
||||
cacheControl: AnthropicEphemeralCacheControl,
|
||||
markerLimit: number,
|
||||
cacheBreakpointOptOutMessageIndexes: ReadonlySet<number>,
|
||||
): void {
|
||||
if (!Array.isArray(messages) || messages.length === 0 || markerLimit <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fallbackToolResult: Record<string, unknown> | undefined;
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (!message || typeof message !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = message as Record<string, unknown>;
|
||||
if (record.role !== "user" || cacheBreakpointOptOutMessageIndexes.has(i)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = record.content;
|
||||
if (typeof content === "string") {
|
||||
if (fallbackToolResult && markerLimit === 1) {
|
||||
fallbackToolResult.cache_control = cacheControl;
|
||||
return;
|
||||
}
|
||||
record.content = [
|
||||
{
|
||||
type: "text",
|
||||
text: content,
|
||||
cache_control: cacheControl,
|
||||
},
|
||||
];
|
||||
if (fallbackToolResult && markerLimit > 1) {
|
||||
fallbackToolResult.cache_control = cacheControl;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let j = content.length - 1; j >= 0; j--) {
|
||||
const block = content[j];
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockRecord = block as Record<string, unknown>;
|
||||
if (blockRecord.type === "text" || blockRecord.type === "image") {
|
||||
if (fallbackToolResult && markerLimit === 1) {
|
||||
fallbackToolResult.cache_control = cacheControl;
|
||||
return;
|
||||
}
|
||||
blockRecord.cache_control = cacheControl;
|
||||
if (fallbackToolResult && markerLimit > 1) {
|
||||
fallbackToolResult.cache_control = cacheControl;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (blockRecord.type === "tool_result" && fallbackToolResult === undefined) {
|
||||
fallbackToolResult = blockRecord;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackToolResult) {
|
||||
fallbackToolResult.cache_control = cacheControl;
|
||||
}
|
||||
}
|
||||
|
||||
function countAnthropicCacheControlMarkers(blocks: unknown): number {
|
||||
if (!Array.isArray(blocks)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const block of blocks) {
|
||||
if (block && typeof block === "object" && "cache_control" in block) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** @deprecated Anthropic-family provider payload helper; do not use from third-party plugins. */
|
||||
export function resolveAnthropicPayloadPolicy(
|
||||
input: AnthropicPayloadPolicyInput,
|
||||
): AnthropicPayloadPolicy {
|
||||
const capabilities = resolveProviderRequestCapabilities({
|
||||
provider: input.provider,
|
||||
api: input.api,
|
||||
baseUrl: input.baseUrl,
|
||||
capability: "llm",
|
||||
transport: "stream",
|
||||
});
|
||||
|
||||
return {
|
||||
allowsServiceTier: capabilities.allowsAnthropicServiceTier,
|
||||
cacheControl:
|
||||
input.enableCacheControl === true
|
||||
? resolveAnthropicEphemeralCacheControl(input.baseUrl, input.cacheRetention)
|
||||
: undefined,
|
||||
serviceTier: input.serviceTier,
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated Anthropic-family provider payload helper; do not use from third-party plugins. */
|
||||
export function applyAnthropicPayloadPolicyToParams(
|
||||
payloadObj: Record<string, unknown>,
|
||||
policy: AnthropicPayloadPolicy,
|
||||
cacheBreakpointOptOutMessageIndexes: ReadonlySet<number>,
|
||||
): void {
|
||||
if (
|
||||
policy.allowsServiceTier &&
|
||||
policy.serviceTier !== undefined &&
|
||||
payloadObj.service_tier === undefined
|
||||
) {
|
||||
payloadObj.service_tier = policy.serviceTier;
|
||||
}
|
||||
|
||||
if (policy.cacheControl) {
|
||||
applyAnthropicCacheControlToSystem(payloadObj.system, policy.cacheControl);
|
||||
} else {
|
||||
stripAnthropicSystemPromptBoundary(payloadObj.system);
|
||||
}
|
||||
|
||||
if (!policy.cacheControl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usedMarkers =
|
||||
countAnthropicCacheControlMarkers(payloadObj.system) +
|
||||
countAnthropicCacheControlMarkers(payloadObj.tools);
|
||||
applyAnthropicCacheControlToMessages(
|
||||
payloadObj.messages,
|
||||
policy.cacheControl,
|
||||
ANTHROPIC_CACHE_CONTROL_LIMIT - usedMarkers,
|
||||
cacheBreakpointOptOutMessageIndexes,
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Anthropic-family provider payload helper; do not use from third-party plugins. */
|
||||
export function applyAnthropicEphemeralCacheControlMarkers(
|
||||
payloadObj: Record<string, unknown>,
|
||||
cacheControl: AnthropicEphemeralCacheControl | null = { type: "ephemeral" },
|
||||
): void {
|
||||
const messages = payloadObj.messages;
|
||||
if (!Array.isArray(messages)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const message of messages as Array<{ role?: string; content?: unknown }>) {
|
||||
if (message.role === "system" || message.role === "developer") {
|
||||
if (!cacheControl) {
|
||||
continue;
|
||||
}
|
||||
if (typeof message.content === "string") {
|
||||
message.content = [{ type: "text", text: message.content, cache_control: cacheControl }];
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(message.content) && message.content.length > 0) {
|
||||
const last = message.content[message.content.length - 1];
|
||||
if (last && typeof last === "object") {
|
||||
const record = last as Record<string, unknown>;
|
||||
if (record.type !== "thinking" && record.type !== "redacted_thinking") {
|
||||
record.cache_control = cacheControl;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = block as Record<string, unknown>;
|
||||
if (record.type === "thinking" || record.type === "redacted_thinking") {
|
||||
delete record.cache_control;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* DeepSeek DSML streaming text filter.
|
||||
* Removes provider-emitted DSML tool markup while buffering split tag prefixes
|
||||
* across streamed chunks.
|
||||
*/
|
||||
const DSML_KINDS = ["tool_use_error", "tool_calls", "tool_call", "function_calls"] as const;
|
||||
const DSML_BARS = ["|", "|"] as const;
|
||||
|
||||
const DSML_OPEN_TOKENS = DSML_BARS.flatMap((bar) =>
|
||||
DSML_KINDS.map((kind) => `<${bar}DSML${bar}${kind}>`),
|
||||
);
|
||||
const DSML_CLOSE_TOKENS = DSML_BARS.flatMap((bar) =>
|
||||
DSML_KINDS.map((kind) => `</${bar}DSML${bar}${kind}>`),
|
||||
);
|
||||
const MAX_OPEN_TOKEN_LEN = Math.max(...DSML_OPEN_TOKENS.map((token) => token.length));
|
||||
const MAX_CLOSE_TOKEN_LEN = Math.max(...DSML_CLOSE_TOKENS.map((token) => token.length));
|
||||
|
||||
interface DeepSeekTextFilter {
|
||||
/** Push one streamed text chunk and receive any safe visible text segments. */
|
||||
push(chunk: string): string[];
|
||||
/** Flush buffered text at stream end, dropping any unterminated DSML block. */
|
||||
flush(): string[];
|
||||
}
|
||||
|
||||
/** Create an incremental text filter that strips DeepSeek DSML tool blocks. */
|
||||
export function createDeepSeekTextFilter(): DeepSeekTextFilter {
|
||||
let buffer = "";
|
||||
let insideDsml = false;
|
||||
|
||||
const consume = (final: boolean): string[] => {
|
||||
const output: string[] = [];
|
||||
const emit = (text: string) => {
|
||||
if (text) {
|
||||
output.push(text);
|
||||
}
|
||||
};
|
||||
|
||||
while (buffer) {
|
||||
if (insideDsml) {
|
||||
const close = findEarliestToken(buffer, DSML_CLOSE_TOKENS);
|
||||
if (close) {
|
||||
buffer = buffer.slice(close.index + close.token.length);
|
||||
insideDsml = false;
|
||||
continue;
|
||||
}
|
||||
// Keep a suffix that could still become a closing tag once the next
|
||||
// streamed chunk arrives; on final flush, drop the unterminated block.
|
||||
const keep = final ? 0 : Math.min(buffer.length, MAX_CLOSE_TOKEN_LEN - 1);
|
||||
buffer = buffer.slice(buffer.length - keep);
|
||||
if (final) {
|
||||
insideDsml = false;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
const open = findEarliestToken(buffer, DSML_OPEN_TOKENS);
|
||||
if (open) {
|
||||
emit(buffer.slice(0, open.index));
|
||||
buffer = buffer.slice(open.index + open.token.length);
|
||||
insideDsml = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (final) {
|
||||
emit(buffer);
|
||||
buffer = "";
|
||||
return output;
|
||||
}
|
||||
|
||||
const keep = longestDsmlOpenPrefixSuffixLength(buffer);
|
||||
const emitLength = buffer.length - keep;
|
||||
if (emitLength <= 0) {
|
||||
return output;
|
||||
}
|
||||
emit(buffer.slice(0, emitLength));
|
||||
buffer = buffer.slice(emitLength);
|
||||
return output;
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
return {
|
||||
push(chunk: string) {
|
||||
buffer += chunk;
|
||||
return consume(false);
|
||||
},
|
||||
flush() {
|
||||
return consume(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findEarliestToken(text: string, tokens: readonly string[]) {
|
||||
let best: { index: number; token: string } | null = null;
|
||||
for (const token of tokens) {
|
||||
const index = text.indexOf(token);
|
||||
if (index !== -1 && (!best || index < best.index)) {
|
||||
best = { index, token };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function longestDsmlOpenPrefixSuffixLength(text: string) {
|
||||
// Preserve only the longest suffix that could be the beginning of a future
|
||||
// opening token, so ordinary text streams immediately.
|
||||
const maxLength = Math.min(text.length, MAX_OPEN_TOKEN_LEN - 1);
|
||||
for (let length = maxLength; length > 0; length--) {
|
||||
const suffix = text.slice(text.length - length);
|
||||
if (DSML_OPEN_TOKENS.some((token) => token.startsWith(suffix))) {
|
||||
return length;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Api, Context, Model } from "@openclaw/llm-core";
|
||||
import { getAiTransportHost, type AiProviderRequestPolicyInput } from "../host.js";
|
||||
|
||||
export function buildGuardedModelFetch(
|
||||
model: Model,
|
||||
timeoutMs?: number,
|
||||
options?: { sanitizeSse?: boolean },
|
||||
): typeof fetch {
|
||||
const host = getAiTransportHost();
|
||||
if (options !== undefined) {
|
||||
return host.buildModelFetch(model, timeoutMs, options) ?? globalThis.fetch;
|
||||
}
|
||||
if (timeoutMs !== undefined) {
|
||||
return host.buildModelFetch(model, timeoutMs) ?? globalThis.fetch;
|
||||
}
|
||||
return host.buildModelFetch(model) ?? globalThis.fetch;
|
||||
}
|
||||
|
||||
export function resolveProviderEndpoint(baseUrl?: string): { endpointClass: string } {
|
||||
return { endpointClass: getAiTransportHost().resolveProviderEndpointClass(baseUrl) };
|
||||
}
|
||||
|
||||
export function resolveProviderRequestCapabilities(input: AiProviderRequestPolicyInput) {
|
||||
return getAiTransportHost().resolveProviderRequestCapabilities(input);
|
||||
}
|
||||
|
||||
export function resolveProviderRequestPolicyConfig(input: {
|
||||
provider?: string;
|
||||
api?: string;
|
||||
baseUrl?: string;
|
||||
capability?: string;
|
||||
transport?: string;
|
||||
providerHeaders?: Record<string, string>;
|
||||
callerHeaders?: Record<string, string>;
|
||||
precedence?: "caller-wins" | "defaults-win";
|
||||
}): { headers?: Record<string, string> } {
|
||||
return { headers: getAiTransportHost().resolveProviderRequestHeaders(input) };
|
||||
}
|
||||
|
||||
export function resolveModelRequestTimeoutMs(model: Model, timeoutMs?: number): number | undefined {
|
||||
return timeoutMs ?? getAiTransportHost().resolveModelRequestTimeoutMs(model);
|
||||
}
|
||||
|
||||
export function resolveOpenAIStrictToolSetting(
|
||||
model: Pick<Model, "provider" | "api" | "baseUrl" | "id"> & { compat?: unknown },
|
||||
options?: { transport?: "stream" | "websocket"; supportsStrictMode?: boolean },
|
||||
): boolean | undefined {
|
||||
return getAiTransportHost().resolveOpenAIStrictToolSetting(model, options);
|
||||
}
|
||||
|
||||
export function transformTransportMessages(
|
||||
messages: Context["messages"],
|
||||
model: Model,
|
||||
normalizeToolCallId?: (
|
||||
id: string,
|
||||
targetModel: Model,
|
||||
source: { provider: string; api: Api; model: string },
|
||||
) => string,
|
||||
options?: {
|
||||
normalizeSameModelToolCallIds?: boolean;
|
||||
preserveCrossModelToolCallThoughtSignature?: boolean;
|
||||
},
|
||||
): Context["messages"] {
|
||||
return getAiTransportHost().transformTransportMessages(
|
||||
messages,
|
||||
model,
|
||||
normalizeToolCallId,
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* JSON parsing helpers that preserve integer literals larger than
|
||||
* Number.MAX_SAFE_INTEGER as strings before JSON.parse can round them.
|
||||
*/
|
||||
const MAX_SAFE_INTEGER_ABS_STR = String(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
function isAsciiDigit(ch: string | undefined): boolean {
|
||||
return ch !== undefined && ch >= "0" && ch <= "9";
|
||||
}
|
||||
|
||||
function parseJsonNumberToken(
|
||||
input: string,
|
||||
start: number,
|
||||
): { token: string; end: number; isInteger: boolean } | null {
|
||||
let idx = start;
|
||||
if (input[idx] === "-") {
|
||||
idx += 1;
|
||||
}
|
||||
if (idx >= input.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input[idx] === "0") {
|
||||
idx += 1;
|
||||
} else if (isAsciiDigit(input[idx]) && input[idx] !== "0") {
|
||||
while (isAsciiDigit(input[idx])) {
|
||||
idx += 1;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
let isInteger = true;
|
||||
if (input[idx] === ".") {
|
||||
isInteger = false;
|
||||
idx += 1;
|
||||
if (!isAsciiDigit(input[idx])) {
|
||||
return null;
|
||||
}
|
||||
while (isAsciiDigit(input[idx])) {
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (input[idx] === "e" || input[idx] === "E") {
|
||||
isInteger = false;
|
||||
idx += 1;
|
||||
if (input[idx] === "+" || input[idx] === "-") {
|
||||
idx += 1;
|
||||
}
|
||||
if (!isAsciiDigit(input[idx])) {
|
||||
return null;
|
||||
}
|
||||
while (isAsciiDigit(input[idx])) {
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token: input.slice(start, idx),
|
||||
end: idx,
|
||||
isInteger,
|
||||
};
|
||||
}
|
||||
|
||||
function isUnsafeIntegerLiteral(token: string): boolean {
|
||||
const digits = token[0] === "-" ? token.slice(1) : token;
|
||||
if (digits.length < MAX_SAFE_INTEGER_ABS_STR.length) {
|
||||
return false;
|
||||
}
|
||||
if (digits.length > MAX_SAFE_INTEGER_ABS_STR.length) {
|
||||
return true;
|
||||
}
|
||||
return digits > MAX_SAFE_INTEGER_ABS_STR;
|
||||
}
|
||||
|
||||
/** Quotes integer literals above Number.MAX_SAFE_INTEGER before JSON.parse. */
|
||||
export function quoteUnsafeIntegerLiterals(input: string): string {
|
||||
let out = "";
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let idx = 0;
|
||||
|
||||
while (idx < input.length) {
|
||||
const ch = input[idx] ?? "";
|
||||
if (inString) {
|
||||
out += ch;
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (ch === "\\") {
|
||||
escaped = true;
|
||||
} else if (ch === '"') {
|
||||
inString = false;
|
||||
}
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
out += ch;
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "-" || isAsciiDigit(ch)) {
|
||||
const parsed = parseJsonNumberToken(input, idx);
|
||||
if (parsed) {
|
||||
if (parsed.isInteger && isUnsafeIntegerLiteral(parsed.token)) {
|
||||
out += `"${parsed.token}"`;
|
||||
} else {
|
||||
out += parsed.token;
|
||||
}
|
||||
idx = parsed.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
out += ch;
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parses JSON while preserving unsafe integer literals as strings. */
|
||||
export function parseJsonPreservingUnsafeIntegers(input: string): unknown {
|
||||
return JSON.parse(quoteUnsafeIntegerLiterals(input)) as unknown;
|
||||
}
|
||||
|
||||
/** Parses or accepts an object while preserving unsafe integer literals in string input. */
|
||||
export function parseJsonObjectPreservingUnsafeIntegers(
|
||||
value: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = parseJsonPreservingUnsafeIntegers(value);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Max-token parameter normalization across provider/native naming variants.
|
||||
* Callers canonicalize aliases before dispatch so payloads cannot carry
|
||||
* conflicting limits.
|
||||
*/
|
||||
const MAX_TOKENS_PARAM_KEYS = ["maxTokens", "max_completion_tokens", "max_tokens"] as const;
|
||||
|
||||
/** Return a finite non-negative max-token value, or undefined for invalid input. */
|
||||
function resolveNonNegativeMaxTokensParam(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** Resolve the first supported max-token parameter present in a params object. */
|
||||
export function resolveMaxTokensParam(
|
||||
params: Record<string, unknown> | undefined,
|
||||
): number | undefined {
|
||||
if (!params) {
|
||||
return undefined;
|
||||
}
|
||||
for (const key of MAX_TOKENS_PARAM_KEYS) {
|
||||
const resolved = resolveNonNegativeMaxTokensParam(params[key]);
|
||||
if (resolved !== undefined) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize merged params to `maxTokens`, preserving source precedence from
|
||||
* left to right across the provided source objects.
|
||||
*/
|
||||
export function canonicalizeMaxTokensParam(params: {
|
||||
merged: Record<string, unknown>;
|
||||
sources: Array<Record<string, unknown> | undefined>;
|
||||
}): void {
|
||||
let resolved: number | undefined;
|
||||
for (const source of params.sources) {
|
||||
const sourceValue = resolveMaxTokensParam(source);
|
||||
if (sourceValue !== undefined) {
|
||||
resolved = sourceValue;
|
||||
}
|
||||
}
|
||||
if (resolved === undefined) {
|
||||
return;
|
||||
}
|
||||
// Delete every spelling before writing the canonical key so callers cannot
|
||||
// send conflicting provider aliases in one payload.
|
||||
for (const key of MAX_TOKENS_PARAM_KEYS) {
|
||||
delete params.merged[key];
|
||||
}
|
||||
params.merged.maxTokens = resolved;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Environment-driven debug controls for model transport logging.
|
||||
*
|
||||
* Model adapters share these helpers so payload, SSE, and transport diagnostics
|
||||
* interpret OpenClaw debug environment variables consistently.
|
||||
*/
|
||||
type SubsystemLogger = {
|
||||
info(message: string): void;
|
||||
debug(message: string): void;
|
||||
};
|
||||
|
||||
type ModelTransportDebugEnv = NodeJS.ProcessEnv;
|
||||
|
||||
/** Payload debug detail levels accepted by `OPENCLAW_DEBUG_MODEL_PAYLOAD`. */
|
||||
type ModelPayloadDebugMode = "off" | "summary" | "tools" | "full-redacted";
|
||||
/** SSE debug detail levels accepted by `OPENCLAW_DEBUG_SSE`. */
|
||||
type ModelSseDebugMode = "off" | "events" | "peek";
|
||||
|
||||
function normalizeEnv(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function isTruthyEnv(value: unknown): boolean {
|
||||
const normalized = normalizeEnv(value);
|
||||
return (
|
||||
normalized.length > 0 &&
|
||||
normalized !== "0" &&
|
||||
normalized !== "false" &&
|
||||
normalized !== "off" &&
|
||||
normalized !== "no"
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves model payload debug verbosity from `OPENCLAW_DEBUG_MODEL_PAYLOAD`. */
|
||||
export function resolveModelPayloadDebugMode(
|
||||
env: ModelTransportDebugEnv = process.env,
|
||||
): ModelPayloadDebugMode {
|
||||
const normalized = normalizeEnv(env.OPENCLAW_DEBUG_MODEL_PAYLOAD);
|
||||
if (normalized === "tools" || normalized === "full-redacted") {
|
||||
return normalized;
|
||||
}
|
||||
if (normalized === "summary") {
|
||||
return "summary";
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
|
||||
/** Resolves SSE stream debug verbosity from `OPENCLAW_DEBUG_SSE`. */
|
||||
export function resolveModelSseDebugMode(
|
||||
env: ModelTransportDebugEnv = process.env,
|
||||
): ModelSseDebugMode {
|
||||
const normalized = normalizeEnv(env.OPENCLAW_DEBUG_SSE);
|
||||
if (normalized === "peek") {
|
||||
return "peek";
|
||||
}
|
||||
if (normalized === "events" || isTruthyEnv(normalized)) {
|
||||
return "events";
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
|
||||
/** Returns whether any model transport debug channel is enabled. */
|
||||
function isModelTransportDebugEnabled(env: ModelTransportDebugEnv = process.env): boolean {
|
||||
return (
|
||||
isTruthyEnv(env.OPENCLAW_DEBUG_MODEL_TRANSPORT) ||
|
||||
resolveModelPayloadDebugMode(env) !== "off" ||
|
||||
resolveModelSseDebugMode(env) !== "off" ||
|
||||
isTruthyEnv(env.OPENCLAW_DEBUG_CODE_MODE)
|
||||
);
|
||||
}
|
||||
|
||||
function isModelFetchMetadataMessage(message: string): boolean {
|
||||
return message.startsWith("[model-fetch]");
|
||||
}
|
||||
|
||||
/** Emits model-fetch metadata at info level by default; other diagnostics require debug env. */
|
||||
export function emitModelTransportDebug(log: SubsystemLogger, message: string): void {
|
||||
if (isModelFetchMetadataMessage(message) || isModelTransportDebugEnabled()) {
|
||||
log.info(message);
|
||||
return;
|
||||
}
|
||||
log.debug(message);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Debug formatting helpers for model transport endpoints.
|
||||
* Keeps logs useful without exposing credentials, request params, or fragments.
|
||||
*/
|
||||
/** Return a sanitized URL suitable for logs and diagnostics. */
|
||||
export function formatModelTransportDebugUrl(rawUrl: string): string {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return "<invalid-url>";
|
||||
}
|
||||
}
|
||||
|
||||
/** Format a configured base URL for debug output, or the implicit default. */
|
||||
export function formatModelTransportDebugBaseUrl(rawUrl: string | undefined): string {
|
||||
return rawUrl ? formatModelTransportDebugUrl(rawUrl) : "default";
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* OpenAI-compatible conversation turn detector.
|
||||
*
|
||||
* Some providers reject requests without a non-empty user/assistant turn; this
|
||||
* helper checks the loose message payload shape before transport submission.
|
||||
*/
|
||||
function hasNonEmptyString(value: unknown): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasNonEmptyContentPart(part: unknown): boolean {
|
||||
if (!part || typeof part !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = part as Record<string, unknown>;
|
||||
if (record.type === "text") {
|
||||
return hasNonEmptyString(record.text);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasNonEmptyMessageContent(content: unknown): boolean {
|
||||
if (hasNonEmptyString(content)) {
|
||||
return true;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return false;
|
||||
}
|
||||
return content.some(hasNonEmptyContentPart);
|
||||
}
|
||||
|
||||
function hasAssistantToolCall(message: Record<string, unknown>): boolean {
|
||||
const toolCalls = message.tool_calls;
|
||||
return (
|
||||
Array.isArray(toolCalls) &&
|
||||
toolCalls.some((toolCall) => {
|
||||
return Boolean(toolCall && typeof toolCall === "object");
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns whether an OpenAI-compatible messages payload contains a usable turn. */
|
||||
export function hasOpenAICompatibleConversationTurn(messages: unknown): boolean {
|
||||
if (!Array.isArray(messages)) {
|
||||
return false;
|
||||
}
|
||||
return messages.some((message) => {
|
||||
if (!message || typeof message !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = message as Record<string, unknown>;
|
||||
if (record.role === "user") {
|
||||
return hasNonEmptyMessageContent(record.content);
|
||||
}
|
||||
if (record.role === "assistant") {
|
||||
return hasNonEmptyMessageContent(record.content) || hasAssistantToolCall(record);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* OpenAI-completions compatibility defaults.
|
||||
*
|
||||
* Provider transports use these helpers to derive OpenAI-compatible request
|
||||
* behavior from endpoint attribution without scattering provider-specific flags.
|
||||
*/
|
||||
import type { Model } from "@openclaw/llm-core";
|
||||
import type { AiProviderRequestCapabilities } from "../host.js";
|
||||
import { resolveProviderRequestCapabilities } from "./host-policy.js";
|
||||
|
||||
type ProviderEndpointClass = string;
|
||||
type ProviderRequestCapabilities = AiProviderRequestCapabilities;
|
||||
|
||||
type OpenAICompletionsCompatDefaultsInput = {
|
||||
provider?: string;
|
||||
endpointClass: ProviderEndpointClass;
|
||||
knownProviderFamily: string;
|
||||
supportsNativeStreamingUsageCompat?: boolean;
|
||||
supportsOpenAICompletionsStreamingUsageCompat?: boolean;
|
||||
usesExplicitProxyLikeEndpoint?: boolean;
|
||||
};
|
||||
|
||||
type OpenAICompletionsCompatDefaults = {
|
||||
supportsStore: boolean;
|
||||
supportsDeveloperRole: boolean;
|
||||
supportsReasoningEffort: boolean;
|
||||
supportsUsageInStreaming: boolean;
|
||||
maxTokensField: "max_completion_tokens" | "max_tokens";
|
||||
thinkingFormat: "openai" | "openrouter" | "deepseek" | "together" | "zai";
|
||||
visibleReasoningDetailTypes: string[];
|
||||
supportsStrictMode: boolean;
|
||||
requiresReasoningContentOnAssistantMessages: boolean;
|
||||
requiresNonEmptyUserOrAssistantMessage: boolean;
|
||||
};
|
||||
|
||||
type DetectedOpenAICompletionsCompat = {
|
||||
capabilities: ProviderRequestCapabilities;
|
||||
defaults: OpenAICompletionsCompatDefaults;
|
||||
};
|
||||
|
||||
function isDefaultRouteProvider(provider: string | undefined, ...ids: string[]) {
|
||||
return provider !== undefined && ids.includes(provider);
|
||||
}
|
||||
|
||||
/** Resolves default request flags for an OpenAI-compatible completions endpoint. */
|
||||
function resolveOpenAICompletionsCompatDefaults(
|
||||
input: OpenAICompletionsCompatDefaultsInput,
|
||||
): OpenAICompletionsCompatDefaults {
|
||||
const {
|
||||
provider,
|
||||
endpointClass,
|
||||
knownProviderFamily,
|
||||
supportsNativeStreamingUsageCompat = false,
|
||||
supportsOpenAICompletionsStreamingUsageCompat = false,
|
||||
usesExplicitProxyLikeEndpoint = false,
|
||||
} = input;
|
||||
const isDefaultRoute = endpointClass === "default";
|
||||
const usesConfiguredNonOpenAIEndpoint =
|
||||
endpointClass !== "default" && endpointClass !== "openai-public";
|
||||
const isMoonshotLike =
|
||||
knownProviderFamily === "moonshot" ||
|
||||
knownProviderFamily === "modelstudio" ||
|
||||
endpointClass === "moonshot-native" ||
|
||||
endpointClass === "modelstudio-native";
|
||||
const isModelStudioLike =
|
||||
knownProviderFamily === "modelstudio" ||
|
||||
endpointClass === "modelstudio-native" ||
|
||||
(isDefaultRoute && isDefaultRouteProvider(provider, "dashscope", "modelstudio", "qwen"));
|
||||
const isZai =
|
||||
endpointClass === "zai-native" ||
|
||||
(isDefaultRoute && isDefaultRouteProvider(input.provider, "zai"));
|
||||
const isDeepSeek =
|
||||
endpointClass === "deepseek-native" ||
|
||||
(isDefaultRoute && isDefaultRouteProvider(input.provider, "deepseek"));
|
||||
const isTogether =
|
||||
knownProviderFamily === "together" ||
|
||||
(isDefaultRoute && isDefaultRouteProvider(input.provider, "together"));
|
||||
const isXiaomi =
|
||||
endpointClass === "xiaomi-native" ||
|
||||
(isDefaultRoute && isDefaultRouteProvider(input.provider, "xiaomi"));
|
||||
const isNonStandard =
|
||||
endpointClass === "cerebras-native" ||
|
||||
endpointClass === "chutes-native" ||
|
||||
endpointClass === "deepseek-native" ||
|
||||
endpointClass === "mistral-public" ||
|
||||
endpointClass === "opencode-native" ||
|
||||
endpointClass === "xai-native" ||
|
||||
isXiaomi ||
|
||||
isZai ||
|
||||
(isDefaultRoute &&
|
||||
isDefaultRouteProvider(input.provider, "cerebras", "chutes", "deepseek", "opencode", "xai"));
|
||||
const isOpenRouterLike = input.provider === "openrouter" || endpointClass === "openrouter";
|
||||
const isLocalEndpoint = endpointClass === "local";
|
||||
const usesMaxTokens =
|
||||
endpointClass === "chutes-native" ||
|
||||
endpointClass === "mistral-public" ||
|
||||
knownProviderFamily === "mistral" ||
|
||||
isZai ||
|
||||
isTogether ||
|
||||
(isDefaultRoute && isDefaultRouteProvider(provider, "chutes"));
|
||||
return {
|
||||
supportsStore:
|
||||
!isNonStandard && knownProviderFamily !== "mistral" && !usesExplicitProxyLikeEndpoint,
|
||||
supportsDeveloperRole: !isNonStandard && !isMoonshotLike && !usesConfiguredNonOpenAIEndpoint,
|
||||
supportsReasoningEffort:
|
||||
!isZai &&
|
||||
!isTogether &&
|
||||
knownProviderFamily !== "mistral" &&
|
||||
endpointClass !== "xai-native" &&
|
||||
!usesExplicitProxyLikeEndpoint,
|
||||
supportsUsageInStreaming:
|
||||
supportsOpenAICompletionsStreamingUsageCompat ||
|
||||
(!isNonStandard &&
|
||||
(isLocalEndpoint ||
|
||||
!usesConfiguredNonOpenAIEndpoint ||
|
||||
supportsNativeStreamingUsageCompat)),
|
||||
maxTokensField: usesMaxTokens ? "max_tokens" : "max_completion_tokens",
|
||||
thinkingFormat:
|
||||
isDeepSeek || isXiaomi
|
||||
? "deepseek"
|
||||
: isZai
|
||||
? "zai"
|
||||
: isTogether
|
||||
? "together"
|
||||
: isOpenRouterLike
|
||||
? "openrouter"
|
||||
: "openai",
|
||||
visibleReasoningDetailTypes: isOpenRouterLike ? ["response.output_text", "response.text"] : [],
|
||||
supportsStrictMode: !isZai && !usesConfiguredNonOpenAIEndpoint,
|
||||
requiresReasoningContentOnAssistantMessages: isDeepSeek || isXiaomi,
|
||||
requiresNonEmptyUserOrAssistantMessage: isModelStudioLike,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpenAICompletionsCompatDefaultsFromCapabilities(
|
||||
input: Pick<
|
||||
ProviderRequestCapabilities,
|
||||
| "endpointClass"
|
||||
| "knownProviderFamily"
|
||||
| "supportsNativeStreamingUsageCompat"
|
||||
| "supportsOpenAICompletionsStreamingUsageCompat"
|
||||
| "usesExplicitProxyLikeEndpoint"
|
||||
> & {
|
||||
provider?: string;
|
||||
},
|
||||
): OpenAICompletionsCompatDefaults {
|
||||
return resolveOpenAICompletionsCompatDefaults(input);
|
||||
}
|
||||
|
||||
/** Detects endpoint capabilities and defaults for an OpenAI-completions model. */
|
||||
export function detectOpenAICompletionsCompat(
|
||||
model: Pick<Model<"openai-completions">, "provider" | "baseUrl" | "id"> & {
|
||||
compat?: { supportsStore?: boolean } | null;
|
||||
},
|
||||
): DetectedOpenAICompletionsCompat {
|
||||
const capabilities = resolveProviderRequestCapabilities({
|
||||
provider: model.provider,
|
||||
api: "openai-completions",
|
||||
baseUrl: model.baseUrl,
|
||||
capability: "llm",
|
||||
transport: "stream",
|
||||
modelId: model.id,
|
||||
compat:
|
||||
model.compat && typeof model.compat === "object"
|
||||
? (model.compat as { supportsStore?: boolean })
|
||||
: undefined,
|
||||
});
|
||||
return {
|
||||
capabilities,
|
||||
defaults: resolveOpenAICompletionsCompatDefaultsFromCapabilities({
|
||||
provider: model.provider,
|
||||
...capabilities,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
(globalThis as Record<PropertyKey, unknown>)[
|
||||
Symbol.for("openclaw.openAICompletionsCompatTestApi")
|
||||
] = { resolveOpenAICompletionsCompatDefaults };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* OpenAI Chat Completions compatibility helpers. Some providers only accept
|
||||
* role/content messages with plain string content instead of text block arrays.
|
||||
*/
|
||||
function flattenStringOnlyCompletionContent(content: unknown): unknown {
|
||||
if (!Array.isArray(content)) {
|
||||
return content;
|
||||
}
|
||||
const textParts: string[] = [];
|
||||
for (const item of content) {
|
||||
if (
|
||||
!item ||
|
||||
typeof item !== "object" ||
|
||||
(item as { type?: unknown }).type !== "text" ||
|
||||
typeof (item as { text?: unknown }).text !== "string"
|
||||
) {
|
||||
return content;
|
||||
}
|
||||
textParts.push((item as { text: string }).text);
|
||||
}
|
||||
return textParts.join("\n");
|
||||
}
|
||||
|
||||
/** Flatten string-only text block content arrays into newline-joined strings. */
|
||||
export function flattenCompletionMessagesToStringContent(messages: unknown[]): unknown[] {
|
||||
return messages.map((message) => {
|
||||
if (!message || typeof message !== "object") {
|
||||
return message;
|
||||
}
|
||||
const content = (message as { content?: unknown }).content;
|
||||
const flattenedContent = flattenStringOnlyCompletionContent(content);
|
||||
if (flattenedContent === content) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
content: flattenedContent,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Strip completion messages to role/content fields for strict providers. */
|
||||
export function stripCompletionMessagesToRoleContent(messages: unknown[]): unknown[] {
|
||||
return messages.map((message) => {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
||||
return message;
|
||||
}
|
||||
const record = message as Record<string, unknown>;
|
||||
const stripped: Record<string, unknown> = {};
|
||||
if (Object.hasOwn(record, "role")) {
|
||||
stripped.role = record.role;
|
||||
}
|
||||
if (Object.hasOwn(record, "content")) {
|
||||
stripped.content = record.content;
|
||||
}
|
||||
return stripped;
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* OpenAI reasoning-effort compatibility helpers.
|
||||
*
|
||||
* Keeps provider metadata and built-in model exceptions on one path before request payloads are built.
|
||||
*/
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
|
||||
/** Minimal model fields needed to resolve OpenAI reasoning effort compatibility. */
|
||||
type OpenAIReasoningCompatModel = {
|
||||
provider?: string | null;
|
||||
id?: string | null;
|
||||
compat?: unknown;
|
||||
};
|
||||
|
||||
// These OpenAI models reject minimal/low reasoning but accept medium. Map lower
|
||||
// efforts up unless provider metadata supplies a more specific compat map.
|
||||
const OPENAI_MEDIUM_ONLY_REASONING_MODEL_IDS = new Set(["gpt-5.1-codex-mini"]);
|
||||
|
||||
// Provider metadata can remap reasoning effort names. Keep only string pairs so
|
||||
// malformed compat data cannot poison request parameters.
|
||||
function readCompatReasoningEffortMap(compat: unknown): Record<string, string> {
|
||||
if (!compat || typeof compat !== "object") {
|
||||
return {};
|
||||
}
|
||||
const rawMap = (compat as { reasoningEffortMap?: unknown }).reasoningEffortMap;
|
||||
if (!rawMap || typeof rawMap !== "object") {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(rawMap).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[0] === "string" && typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves the reasoning effort remap for an OpenAI-compatible model. */
|
||||
export function resolveOpenAIReasoningEffortMap(
|
||||
model: OpenAIReasoningCompatModel,
|
||||
fallbackMap: Record<string, string> = {},
|
||||
): Record<string, string> {
|
||||
const provider = normalizeLowercaseStringOrEmpty(model.provider ?? "");
|
||||
const id = normalizeLowercaseStringOrEmpty(model.id ?? "");
|
||||
const builtinMap: Record<string, string> =
|
||||
provider === "openai" && OPENAI_MEDIUM_ONLY_REASONING_MODEL_IDS.has(id)
|
||||
? { minimal: "medium", low: "medium" }
|
||||
: {};
|
||||
return {
|
||||
...fallbackMap,
|
||||
...builtinMap,
|
||||
...readCompatReasoningEffortMap(model.compat),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion";
|
||||
/**
|
||||
* OpenAI Responses payload policy.
|
||||
* Classifies endpoint capabilities and applies store, prompt-cache,
|
||||
* server-compaction, service-tier, and reasoning payload rules.
|
||||
*/
|
||||
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
|
||||
import { supportsOpenAIReasoningEffort } from "../internal/openai.js";
|
||||
|
||||
type OpenAIResponsesPayloadModel = {
|
||||
api?: unknown;
|
||||
baseUrl?: unknown;
|
||||
id?: unknown;
|
||||
provider?: unknown;
|
||||
contextWindow?: unknown;
|
||||
compat?: unknown;
|
||||
};
|
||||
|
||||
type OpenAIResponsesPayloadPolicyOptions = {
|
||||
extraParams?: Record<string, unknown>;
|
||||
storeMode?: "provider-policy" | "disable" | "preserve";
|
||||
enablePromptCacheStripping?: boolean;
|
||||
enableServerCompaction?: boolean;
|
||||
};
|
||||
|
||||
type OpenAIResponsesEndpointClass =
|
||||
| "default"
|
||||
| "anthropic-public"
|
||||
| "cerebras-native"
|
||||
| "chutes-native"
|
||||
| "deepseek-native"
|
||||
| "github-copilot-native"
|
||||
| "groq-native"
|
||||
| "mistral-public"
|
||||
| "moonshot-native"
|
||||
| "modelstudio-native"
|
||||
| "openai-public"
|
||||
| "openai"
|
||||
| "opencode-native"
|
||||
| "azure-openai"
|
||||
| "openrouter"
|
||||
| "xai-native"
|
||||
| "zai-native"
|
||||
| "google-generative-ai"
|
||||
| "google-vertex"
|
||||
| "local"
|
||||
| "custom"
|
||||
| "invalid";
|
||||
|
||||
type OpenAIResponsesPayloadPolicy = {
|
||||
allowsServiceTier: boolean;
|
||||
compactThreshold: number;
|
||||
explicitStore: boolean | undefined;
|
||||
shouldStripDisabledReasoningPayload: boolean;
|
||||
shouldStripInputStatus: boolean;
|
||||
shouldStripPromptCache: boolean;
|
||||
shouldStripStore: boolean;
|
||||
useServerCompaction: boolean;
|
||||
};
|
||||
|
||||
type OpenAIResponsesPayloadCapabilities = {
|
||||
allowsOpenAIServiceTier: boolean;
|
||||
allowsResponsesStore: boolean;
|
||||
shouldStripResponsesPromptCache: boolean;
|
||||
supportsResponsesStoreField: boolean;
|
||||
usesKnownNativeOpenAIRoute: boolean;
|
||||
};
|
||||
|
||||
const OPENAI_RESPONSES_APIS = new Set([
|
||||
"openai-responses",
|
||||
"azure-openai-responses",
|
||||
"openai-chatgpt-responses",
|
||||
"openclaw-openai-responses-transport",
|
||||
]);
|
||||
const OPENAI_RESPONSES_PROVIDERS = new Set(["openai", "azure-openai", "azure-openai-responses"]);
|
||||
const LOCAL_ENDPOINT_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
||||
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 MOONSHOT_NATIVE_BASE_URLS = new Set([
|
||||
"https://api.moonshot.ai/v1",
|
||||
"https://api.moonshot.cn/v1",
|
||||
]);
|
||||
|
||||
function normalizeLowercaseString(value: unknown): string | undefined {
|
||||
const stringValue = readStringValue(value)?.trim().toLowerCase();
|
||||
return stringValue ? stringValue : undefined;
|
||||
}
|
||||
|
||||
function normalizeComparableBaseUrl(value: unknown): string | undefined {
|
||||
const trimmed = readStringValue(value)?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const parsedValue = /^[a-z0-9.[\]-]+(?::\d+)?(?:[/?#].*)?$/i.test(trimmed)
|
||||
? `https://${trimmed}`
|
||||
: 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 resolveUrlHostname(value: unknown): string | undefined {
|
||||
const trimmed = readStringValue(value)?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(trimmed).hostname.toLowerCase();
|
||||
} catch {
|
||||
try {
|
||||
return new URL(`https://${trimmed}`).hostname.toLowerCase();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hostMatchesSuffix(host: string, suffix: string): boolean {
|
||||
return suffix.startsWith(".") || suffix.startsWith("-")
|
||||
? host.endsWith(suffix)
|
||||
: host === suffix || host.endsWith(`.${suffix}`);
|
||||
}
|
||||
|
||||
function isLocalEndpointHost(host: string): boolean {
|
||||
return (
|
||||
LOCAL_ENDPOINT_HOSTS.has(host) ||
|
||||
host.endsWith(".localhost") ||
|
||||
host.endsWith(".local") ||
|
||||
host.endsWith(".internal")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBundledOpenAIResponsesEndpointClass(
|
||||
baseUrl: unknown,
|
||||
): OpenAIResponsesEndpointClass {
|
||||
const trimmed = readStringValue(baseUrl)?.trim();
|
||||
if (!trimmed) {
|
||||
return "default";
|
||||
}
|
||||
const host = resolveUrlHostname(trimmed);
|
||||
if (!host) {
|
||||
return "invalid";
|
||||
}
|
||||
const comparableBaseUrl = normalizeComparableBaseUrl(trimmed);
|
||||
|
||||
switch (host) {
|
||||
case "api.anthropic.com":
|
||||
return "anthropic-public";
|
||||
case "api.cerebras.ai":
|
||||
return "cerebras-native";
|
||||
case "llm.chutes.ai":
|
||||
return "chutes-native";
|
||||
case "api.deepseek.com":
|
||||
return "deepseek-native";
|
||||
case "api.groq.com":
|
||||
return "groq-native";
|
||||
case "api.mistral.ai":
|
||||
return "mistral-public";
|
||||
case "api.openai.com":
|
||||
return "openai-public";
|
||||
case "chatgpt.com":
|
||||
return "openai";
|
||||
case "generativelanguage.googleapis.com":
|
||||
return "google-generative-ai";
|
||||
case "aiplatform.googleapis.com":
|
||||
return "google-vertex";
|
||||
case "api.x.ai":
|
||||
return "xai-native";
|
||||
case "api.z.ai":
|
||||
return "zai-native";
|
||||
}
|
||||
|
||||
if (hostMatchesSuffix(host, ".githubcopilot.com")) {
|
||||
return "github-copilot-native";
|
||||
}
|
||||
if (hostMatchesSuffix(host, ".openai.azure.com")) {
|
||||
return "azure-openai";
|
||||
}
|
||||
if (hostMatchesSuffix(host, "openrouter.ai")) {
|
||||
return "openrouter";
|
||||
}
|
||||
if (hostMatchesSuffix(host, "opencode.ai")) {
|
||||
return "opencode-native";
|
||||
}
|
||||
if (hostMatchesSuffix(host, "-aiplatform.googleapis.com")) {
|
||||
return "google-vertex";
|
||||
}
|
||||
if (comparableBaseUrl && MOONSHOT_NATIVE_BASE_URLS.has(comparableBaseUrl)) {
|
||||
return "moonshot-native";
|
||||
}
|
||||
if (comparableBaseUrl && MODELSTUDIO_NATIVE_BASE_URLS.has(comparableBaseUrl)) {
|
||||
return "modelstudio-native";
|
||||
}
|
||||
if (isLocalEndpointHost(host)) {
|
||||
return "local";
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function isOpenAIResponsesApi(api: string | undefined): boolean {
|
||||
return api !== undefined && OPENAI_RESPONSES_APIS.has(api);
|
||||
}
|
||||
|
||||
function readCompatPayloadBoolean(
|
||||
compat: unknown,
|
||||
key: "supportsPromptCacheKey" | "supportsStore",
|
||||
): boolean | undefined {
|
||||
if (!compat || typeof compat !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const value = (compat as Record<string, unknown>)[key];
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function resolveOpenAIResponsesPayloadCapabilities(
|
||||
model: OpenAIResponsesPayloadModel,
|
||||
): OpenAIResponsesPayloadCapabilities {
|
||||
const provider = normalizeLowercaseString(model.provider);
|
||||
const api = normalizeLowercaseString(model.api);
|
||||
const isOpenAIProvider = provider === "openai";
|
||||
const endpointClass = resolveBundledOpenAIResponsesEndpointClass(model.baseUrl);
|
||||
const isResponsesApi = isOpenAIResponsesApi(api);
|
||||
const usesConfiguredBaseUrl = endpointClass !== "default";
|
||||
const usesKnownNativeOpenAIEndpoint =
|
||||
endpointClass === "openai-public" ||
|
||||
endpointClass === "openai" ||
|
||||
endpointClass === "azure-openai";
|
||||
const usesKnownNativeOpenAIRoute =
|
||||
endpointClass === "default" ? provider === "openai" : usesKnownNativeOpenAIEndpoint;
|
||||
const usesExplicitProxyLikeEndpoint = usesConfiguredBaseUrl && !usesKnownNativeOpenAIEndpoint;
|
||||
const promptCacheKeySupport = readCompatPayloadBoolean(model.compat, "supportsPromptCacheKey");
|
||||
const shouldStripResponsesPromptCache =
|
||||
promptCacheKeySupport === true
|
||||
? false
|
||||
: promptCacheKeySupport === false
|
||||
? isResponsesApi
|
||||
: isResponsesApi && usesExplicitProxyLikeEndpoint;
|
||||
const supportsResponsesStoreField =
|
||||
readCompatPayloadBoolean(model.compat, "supportsStore") !== false && isResponsesApi;
|
||||
|
||||
return {
|
||||
allowsOpenAIServiceTier:
|
||||
(provider === "openai" &&
|
||||
(api === "openai-responses" || api === "openclaw-openai-responses-transport") &&
|
||||
endpointClass === "openai-public") ||
|
||||
(isOpenAIProvider &&
|
||||
(api === "openai-chatgpt-responses" ||
|
||||
api === "openai-responses" ||
|
||||
api === "openclaw-openai-responses-transport") &&
|
||||
endpointClass === "openai"),
|
||||
allowsResponsesStore:
|
||||
supportsResponsesStoreField &&
|
||||
api !== "openai-chatgpt-responses" &&
|
||||
provider !== undefined &&
|
||||
OPENAI_RESPONSES_PROVIDERS.has(provider) &&
|
||||
usesKnownNativeOpenAIEndpoint,
|
||||
shouldStripResponsesPromptCache,
|
||||
supportsResponsesStoreField,
|
||||
usesKnownNativeOpenAIRoute,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return parseStrictPositiveInteger(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveOpenAIResponsesCompactThreshold(model: { contextWindow?: unknown }): number {
|
||||
const contextWindow = parsePositiveInteger(model.contextWindow);
|
||||
if (contextWindow) {
|
||||
return Math.max(1_000, Math.floor(contextWindow * 0.7));
|
||||
}
|
||||
return 80_000;
|
||||
}
|
||||
|
||||
function shouldEnableOpenAIResponsesServerCompaction(
|
||||
explicitStore: boolean | undefined,
|
||||
provider: unknown,
|
||||
extraParams: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
const configured = extraParams?.responsesServerCompaction;
|
||||
if (configured === false) {
|
||||
return false;
|
||||
}
|
||||
if (explicitStore !== true) {
|
||||
return false;
|
||||
}
|
||||
if (configured === true) {
|
||||
return true;
|
||||
}
|
||||
return provider === "openai";
|
||||
}
|
||||
|
||||
function stripDisabledOpenAIReasoningPayload(payloadObj: Record<string, unknown>): void {
|
||||
const reasoning = payloadObj.reasoning;
|
||||
if (reasoning === "none") {
|
||||
delete payloadObj.reasoning;
|
||||
return;
|
||||
}
|
||||
if (!reasoning || typeof reasoning !== "object" || Array.isArray(reasoning)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Some Responses models and OpenAI-compatible proxies reject
|
||||
// `reasoning.effort: "none"`. Treat unsupported disabled effort as omitted.
|
||||
const reasoningObj = reasoning as Record<string, unknown>;
|
||||
if (reasoningObj.effort === "none") {
|
||||
delete payloadObj.reasoning;
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip returned-item metadata rejected by strict Responses-compatible endpoints. */
|
||||
function stripInputItemStatuses(input: unknown): void {
|
||||
if (!Array.isArray(input)) {
|
||||
return;
|
||||
}
|
||||
for (const item of input) {
|
||||
if (item && typeof item === "object" && !Array.isArray(item)) {
|
||||
// Only item-level status is provider metadata. Nested values may be user or tool payloads.
|
||||
delete (item as Record<string, unknown>).status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve payload mutation policy for one OpenAI Responses-style model endpoint. */
|
||||
export function resolveOpenAIResponsesPayloadPolicy(
|
||||
model: OpenAIResponsesPayloadModel,
|
||||
options: OpenAIResponsesPayloadPolicyOptions = {},
|
||||
): OpenAIResponsesPayloadPolicy {
|
||||
const capabilities = resolveOpenAIResponsesPayloadCapabilities(model);
|
||||
const storeMode = options.storeMode ?? "provider-policy";
|
||||
const explicitStore =
|
||||
storeMode === "preserve"
|
||||
? undefined
|
||||
: storeMode === "disable"
|
||||
? capabilities.supportsResponsesStoreField
|
||||
? false
|
||||
: undefined
|
||||
: capabilities.allowsResponsesStore
|
||||
? true
|
||||
: undefined;
|
||||
const isResponsesApi = isOpenAIResponsesApi(normalizeLowercaseString(model.api));
|
||||
const shouldStripDisabledReasoningPayload =
|
||||
isResponsesApi &&
|
||||
(!capabilities.usesKnownNativeOpenAIRoute || !supportsOpenAIReasoningEffort(model, "none"));
|
||||
// Strict OpenAI-compatible Responses endpoints reject output-only fields
|
||||
// such as `status` on replayed input items. Strip them for non-native routes.
|
||||
const shouldStripInputStatus = isResponsesApi && !capabilities.usesKnownNativeOpenAIRoute;
|
||||
|
||||
return {
|
||||
allowsServiceTier: capabilities.allowsOpenAIServiceTier,
|
||||
compactThreshold:
|
||||
parsePositiveInteger(options.extraParams?.responsesCompactThreshold) ??
|
||||
resolveOpenAIResponsesCompactThreshold(model),
|
||||
explicitStore,
|
||||
shouldStripDisabledReasoningPayload,
|
||||
shouldStripInputStatus,
|
||||
shouldStripPromptCache:
|
||||
options.enablePromptCacheStripping === true && capabilities.shouldStripResponsesPromptCache,
|
||||
shouldStripStore:
|
||||
explicitStore !== true &&
|
||||
readCompatPayloadBoolean(model.compat, "supportsStore") === false &&
|
||||
isResponsesApi,
|
||||
useServerCompaction:
|
||||
options.enableServerCompaction === true &&
|
||||
shouldEnableOpenAIResponsesServerCompaction(
|
||||
explicitStore,
|
||||
model.provider,
|
||||
options.extraParams,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Mutate a Responses request payload according to the resolved endpoint policy. */
|
||||
export function applyOpenAIResponsesPayloadPolicy(
|
||||
payloadObj: Record<string, unknown>,
|
||||
policy: OpenAIResponsesPayloadPolicy,
|
||||
): void {
|
||||
if (policy.explicitStore !== undefined) {
|
||||
payloadObj.store = policy.explicitStore;
|
||||
}
|
||||
if (policy.shouldStripStore) {
|
||||
delete payloadObj.store;
|
||||
}
|
||||
if (policy.shouldStripPromptCache) {
|
||||
delete payloadObj.prompt_cache_key;
|
||||
delete payloadObj.prompt_cache_retention;
|
||||
}
|
||||
if (policy.useServerCompaction && payloadObj.context_management === undefined) {
|
||||
payloadObj.context_management = [
|
||||
{
|
||||
type: "compaction",
|
||||
compact_threshold: policy.compactThreshold,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (policy.shouldStripDisabledReasoningPayload) {
|
||||
stripDisabledOpenAIReasoningPayload(payloadObj);
|
||||
}
|
||||
if (policy.shouldStripInputStatus) {
|
||||
stripInputItemStatuses(payloadObj.input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Resolves the assistant message id that can be replayed to OpenAI Responses. */
|
||||
export function resolveReplayableResponsesMessageId(params: {
|
||||
replayResponsesItemIds: boolean;
|
||||
textSignatureId?: string;
|
||||
fallbackId: string;
|
||||
fallbackOrdinal: number;
|
||||
previousReplayItemWasReasoning: boolean;
|
||||
}): string | undefined {
|
||||
if (!params.replayResponsesItemIds) {
|
||||
return undefined;
|
||||
}
|
||||
if (!params.textSignatureId) {
|
||||
// Id-less text signatures get a deterministic synthetic id per fallback
|
||||
// ordinal; signed text can only replay when paired with preceding reasoning.
|
||||
return params.fallbackOrdinal === 0
|
||||
? params.fallbackId
|
||||
: `${params.fallbackId}_${params.fallbackOrdinal}`;
|
||||
}
|
||||
return params.previousReplayItemWasReasoning ? params.textSignatureId : undefined;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Model } from "@openclaw/llm-core";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import type OpenAI from "openai";
|
||||
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
|
||||
// Terminal-outcome recording for the agent-side Responses stream.
|
||||
//
|
||||
// `response.completed` and `response.incomplete` are both terminal and both carry usage, so they
|
||||
// finalize through one path here. Splitting them is how incomplete turns silently recorded zero
|
||||
// usage. The mapping itself is owned by the package provider helpers, shared with the
|
||||
// package-side processor; this module is the agent-specific adapter that adds reasoning-token
|
||||
// accounting on top and works off raw records rather than typed SDK events.
|
||||
import {
|
||||
mapResponsesTerminalUsage,
|
||||
readResponsesReasoningTokens,
|
||||
resolveResponsesTerminalStopReason,
|
||||
type ResponsesTerminalUsagePayload,
|
||||
} from "../internal/openai.js";
|
||||
import { calculateCost } from "../internal/runtime.js";
|
||||
import type { MutableAssistantOutput } from "./openai-transport-shared.js";
|
||||
|
||||
function readIncompleteReason(response: Record<string, unknown> | undefined): string | undefined {
|
||||
const details = response?.incomplete_details;
|
||||
if (!isRecord(details)) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof details.reason === "string" ? details.reason : undefined;
|
||||
}
|
||||
|
||||
/** Record usage, cost and stop reason from a terminal Responses event onto the output. */
|
||||
export function recordResponsesTerminalOutcome(params: {
|
||||
response: Record<string, unknown> | undefined;
|
||||
output: MutableAssistantOutput;
|
||||
model: Model;
|
||||
serviceTier?: ResponseCreateParamsStreaming["service_tier"];
|
||||
applyServiceTierPricing?: (
|
||||
usage: MutableAssistantOutput["usage"],
|
||||
serviceTier?: ResponseCreateParamsStreaming["service_tier"],
|
||||
) => void;
|
||||
}): void {
|
||||
const { response, output, model } = params;
|
||||
const usage = response?.usage as ResponsesTerminalUsagePayload | undefined;
|
||||
const mappedUsage = mapResponsesTerminalUsage(usage);
|
||||
if (mappedUsage) {
|
||||
const reasoningTokens = readResponsesReasoningTokens(usage);
|
||||
output.usage = {
|
||||
...mappedUsage,
|
||||
...(reasoningTokens === undefined ? {} : { reasoningTokens }),
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
calculateCost(model as never, output.usage as never);
|
||||
if (params.applyServiceTierPricing) {
|
||||
params.applyServiceTierPricing(
|
||||
output.usage,
|
||||
(response?.service_tier as ResponseCreateParamsStreaming["service_tier"] | undefined) ??
|
||||
params.serviceTier,
|
||||
);
|
||||
}
|
||||
const terminal = resolveResponsesTerminalStopReason({
|
||||
status: response?.status as OpenAI.Responses.ResponseStatus | undefined,
|
||||
incompleteReason: readIncompleteReason(response),
|
||||
hasToolCall: output.content.some((block) => block.type === "toolCall"),
|
||||
});
|
||||
output.stopReason = terminal.stopReason;
|
||||
if (terminal.errorMessage) {
|
||||
output.errorMessage = terminal.errorMessage;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
import type { Context, Model } from "@openclaw/llm-core";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { getAiTransportHost } from "../host.js";
|
||||
import {
|
||||
findOpenAIStrictToolProjectionDiagnostics,
|
||||
resolveOpenAIProjectedToolsStrictToolFlag,
|
||||
type OpenAIToolProjection,
|
||||
} from "../internal/openai.js";
|
||||
import { resolveModelRequestTimeoutMs, resolveProviderRequestPolicyConfig } from "./host-policy.js";
|
||||
import { detectOpenAICompletionsCompat } from "./openai-completions-compat.js";
|
||||
import { resolveOpenAIReasoningEffortMap } from "./openai-reasoning-compat.js";
|
||||
import type { OpenAIModeModel } from "./openai-transport-shared.js";
|
||||
import { isCodeModeModelVisibleToolName, sha256Hex } from "./transport-utils.js";
|
||||
|
||||
const MAX_OPENAI_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS = 256;
|
||||
const OPENAI_CODEX_RESPONSES_PROVIDERS = new Set(["openai"]);
|
||||
const loggedOpenAIStrictToolDowngradeDiagnosticKeys = new Set<string>();
|
||||
|
||||
function readToolPayloadField(record: Record<string, unknown>, field: string): unknown {
|
||||
try {
|
||||
return record[field];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function transportPayloadToolName(tool: unknown): string | undefined {
|
||||
if (!isRecord(tool)) {
|
||||
return undefined;
|
||||
}
|
||||
const name = readToolPayloadField(tool, "name");
|
||||
if (typeof name === "string") {
|
||||
return name;
|
||||
}
|
||||
const fn = readToolPayloadField(tool, "function");
|
||||
if (!isRecord(fn)) {
|
||||
return undefined;
|
||||
}
|
||||
const fnName = readToolPayloadField(fn, "name");
|
||||
return typeof fnName === "string" ? fnName : undefined;
|
||||
}
|
||||
|
||||
export function enforceCodeModeResponsesToolSurface(payload: unknown): void {
|
||||
if (!isRecord(payload) || !Array.isArray(payload.tools)) {
|
||||
return;
|
||||
}
|
||||
payload.tools = payload.tools.filter((tool) => {
|
||||
const name = transportPayloadToolName(tool);
|
||||
return typeof name === "string" && isCodeModeModelVisibleToolName(name);
|
||||
});
|
||||
}
|
||||
|
||||
export function assertCodeModeResponsesToolSurface(payload: unknown): void {
|
||||
if (!isRecord(payload) || !Array.isArray(payload.tools)) {
|
||||
throw new Error("Code mode payload tool surface violation: expected exec,wait; got no tools");
|
||||
}
|
||||
const names = payload.tools
|
||||
.map(transportPayloadToolName)
|
||||
.filter((name): name is string => typeof name === "string" && name.length > 0)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
if (
|
||||
names.length >= 2 &&
|
||||
new Set(names).size === names.length &&
|
||||
names.filter((name) => name === "exec").length === 1 &&
|
||||
names.filter((name) => name === "wait").length === 1 &&
|
||||
names.every(isCodeModeModelVisibleToolName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Code mode payload tool surface violation: expected exec,wait plus direct-only tools; got ${
|
||||
names.length > 0 ? names.join(",") : "none"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpenAIStrictToolDowngradeDiagnosticKey(
|
||||
diagnostics: ReturnType<typeof findOpenAIStrictToolProjectionDiagnostics>,
|
||||
context: { transport: "responses" | "completions"; model: OpenAIModeModel },
|
||||
): string {
|
||||
return sha256Hex(
|
||||
JSON.stringify({
|
||||
transport: context.transport,
|
||||
provider: context.model.provider ?? null,
|
||||
model: context.model.id ?? null,
|
||||
diagnostics: diagnostics.map((entry) => ({
|
||||
toolIndex: entry.toolIndex,
|
||||
toolName: entry.toolName ?? null,
|
||||
violations: entry.violations,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldLogOpenAIStrictToolDowngradeDiagnostic(
|
||||
diagnostics: ReturnType<typeof findOpenAIStrictToolProjectionDiagnostics>,
|
||||
context: { transport: "responses" | "completions"; model: OpenAIModeModel },
|
||||
): boolean {
|
||||
const key = buildOpenAIStrictToolDowngradeDiagnosticKey(diagnostics, context);
|
||||
if (loggedOpenAIStrictToolDowngradeDiagnosticKeys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
loggedOpenAIStrictToolDowngradeDiagnosticKeys.size >=
|
||||
MAX_OPENAI_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS
|
||||
) {
|
||||
loggedOpenAIStrictToolDowngradeDiagnosticKeys.clear();
|
||||
}
|
||||
loggedOpenAIStrictToolDowngradeDiagnosticKeys.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveOpenAIStrictToolFlagWithDiagnostics(
|
||||
projection: OpenAIToolProjection,
|
||||
strictSetting: boolean | null | undefined,
|
||||
context: { transport: "responses" | "completions"; model: OpenAIModeModel },
|
||||
): boolean | undefined {
|
||||
const strict = resolveOpenAIProjectedToolsStrictToolFlag(projection, strictSetting);
|
||||
if (strictSetting === true && strict === false) {
|
||||
const diagnostics = findOpenAIStrictToolProjectionDiagnostics(projection);
|
||||
getAiTransportHost().logDebug("openai-transport", () => {
|
||||
if (!shouldLogOpenAIStrictToolDowngradeDiagnostic(diagnostics, context)) {
|
||||
return null;
|
||||
}
|
||||
const sample = diagnostics.slice(0, 5).map((entry) => ({
|
||||
tool: entry.toolName ?? `tool[${entry.toolIndex}]`,
|
||||
violations: entry.violations.slice(0, 8),
|
||||
}));
|
||||
return {
|
||||
message:
|
||||
`OpenAI ${context.transport} tool schema strict mode downgraded to strict=false for ` +
|
||||
`${context.model.provider ?? "unknown"}/${context.model.id ?? "unknown"} ` +
|
||||
`because ${diagnostics.length} tool schema(s) are not strict-compatible`,
|
||||
data: {
|
||||
transport: context.transport,
|
||||
provider: context.model.provider,
|
||||
model: context.model.id,
|
||||
incompatibleToolCount: diagnostics.length,
|
||||
sample,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
return strict;
|
||||
}
|
||||
|
||||
export function isOpenAICodexResponsesModel(model: Model): boolean {
|
||||
return (
|
||||
OPENAI_CODEX_RESPONSES_PROVIDERS.has(model.provider) &&
|
||||
(model.api === "openai-chatgpt-responses" ||
|
||||
model.api === "openclaw-openai-responses-transport")
|
||||
);
|
||||
}
|
||||
|
||||
function isNativeOpenAICodexResponsesBaseUrl(baseUrl?: string): boolean {
|
||||
const trimmed = typeof baseUrl === "string" ? baseUrl.trim() : "";
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
if (url.hostname.toLowerCase() !== "chatgpt.com") {
|
||||
return false;
|
||||
}
|
||||
const pathname = url.pathname.replace(/\/+$/u, "").toLowerCase();
|
||||
return [
|
||||
"/backend-api",
|
||||
"/backend-api/v1",
|
||||
"/backend-api/codex",
|
||||
"/backend-api/codex/v1",
|
||||
].includes(pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function usesNativeOpenAICodexResponsesBackend(model: Model): boolean {
|
||||
return isOpenAICodexResponsesModel(model) && isNativeOpenAICodexResponsesBaseUrl(model.baseUrl);
|
||||
}
|
||||
|
||||
export function buildOpenAIClientHeaders(
|
||||
model: Model,
|
||||
context: Context,
|
||||
optionHeaders?: Record<string, string>,
|
||||
turnHeaders?: Record<string, string>,
|
||||
sessionId?: string,
|
||||
): Record<string, string> {
|
||||
const providerHeaders = { ...model.headers };
|
||||
if (model.provider === "github-copilot") {
|
||||
Object.assign(
|
||||
providerHeaders,
|
||||
getAiTransportHost().buildCopilotDynamicHeaders(context.messages),
|
||||
);
|
||||
}
|
||||
const callerHeaders = { ...optionHeaders, ...turnHeaders };
|
||||
const headers = resolveProviderRequestPolicyConfig({
|
||||
provider: model.provider,
|
||||
api: model.api,
|
||||
baseUrl: model.baseUrl,
|
||||
capability: "llm",
|
||||
transport: "stream",
|
||||
providerHeaders,
|
||||
callerHeaders: Object.keys(callerHeaders).length > 0 ? callerHeaders : undefined,
|
||||
precedence: "caller-wins",
|
||||
}).headers;
|
||||
const resolvedHeaders = headers ?? {};
|
||||
// Preserve ChatGPT Responses session affinity; the native backend accepts this spelling.
|
||||
if (
|
||||
sessionId &&
|
||||
!Object.keys(resolvedHeaders).some(
|
||||
(key) => normalizeLowercaseStringOrEmpty(key) === "session_id",
|
||||
) &&
|
||||
usesNativeOpenAICodexResponsesBackend(model)
|
||||
) {
|
||||
resolvedHeaders.session_id = sessionId;
|
||||
}
|
||||
return resolvedHeaders;
|
||||
}
|
||||
|
||||
function resolveOpenAISdkTimeoutMs(model: Model): number | undefined {
|
||||
return resolveModelRequestTimeoutMs(model, undefined);
|
||||
}
|
||||
|
||||
export function buildOpenAISdkClientOptions(model: Model): { timeout?: number } {
|
||||
const timeout = resolveOpenAISdkTimeoutMs(model);
|
||||
return timeout === undefined ? {} : { timeout };
|
||||
}
|
||||
|
||||
export function buildOpenAISdkRequestOptions(
|
||||
model: Model,
|
||||
signal?: AbortSignal,
|
||||
options?: { stream?: boolean },
|
||||
): { signal?: AbortSignal; timeout?: number; headers?: Record<string, string> } | undefined {
|
||||
const timeout = resolveOpenAISdkTimeoutMs(model);
|
||||
const headers =
|
||||
options?.stream === true && usesNativeOpenAICodexResponsesBackend(model)
|
||||
? { Accept: "text/event-stream" }
|
||||
: undefined;
|
||||
if (timeout === undefined && !signal && !headers) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(headers ? { headers } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
...(timeout !== undefined ? { timeout } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function detectCompat(model: OpenAIModeModel) {
|
||||
const { defaults } = detectOpenAICompletionsCompat(model);
|
||||
return {
|
||||
supportsStore: defaults.supportsStore,
|
||||
supportsDeveloperRole: defaults.supportsDeveloperRole,
|
||||
supportsReasoningEffort: defaults.supportsReasoningEffort,
|
||||
reasoningEffortMap: {},
|
||||
supportsUsageInStreaming: defaults.supportsUsageInStreaming,
|
||||
maxTokensField: defaults.maxTokensField,
|
||||
requiresToolResultName: false,
|
||||
requiresAssistantAfterToolResult: false,
|
||||
requiresThinkingAsText: false,
|
||||
thinkingFormat: defaults.thinkingFormat,
|
||||
visibleReasoningDetailTypes: defaults.visibleReasoningDetailTypes,
|
||||
openRouterRouting: {},
|
||||
vercelGatewayRouting: {},
|
||||
supportsStrictMode: defaults.supportsStrictMode,
|
||||
requiresReasoningContentOnAssistantMessages:
|
||||
defaults.requiresReasoningContentOnAssistantMessages,
|
||||
requiresNonEmptyUserOrAssistantMessage: defaults.requiresNonEmptyUserOrAssistantMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCompat(model: OpenAIModeModel) {
|
||||
const detected = detectCompat(model);
|
||||
const compat = model.compat ?? {};
|
||||
const supportsStore =
|
||||
typeof compat.supportsStore === "boolean" ? compat.supportsStore : detected.supportsStore;
|
||||
const supportsReasoningEffort =
|
||||
typeof compat.supportsReasoningEffort === "boolean"
|
||||
? compat.supportsReasoningEffort
|
||||
: detected.supportsReasoningEffort;
|
||||
return {
|
||||
supportsStore,
|
||||
supportsDeveloperRole: compat.supportsDeveloperRole ?? detected.supportsDeveloperRole,
|
||||
supportsReasoningEffort,
|
||||
reasoningEffortMap: resolveOpenAIReasoningEffortMap(model, detected.reasoningEffortMap),
|
||||
supportsUsageInStreaming: compat.supportsUsageInStreaming ?? detected.supportsUsageInStreaming,
|
||||
maxTokensField: (compat.maxTokensField as string | undefined) ?? detected.maxTokensField,
|
||||
requiresToolResultName: compat.requiresToolResultName ?? detected.requiresToolResultName,
|
||||
requiresAssistantAfterToolResult:
|
||||
compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult,
|
||||
requiresThinkingAsText: compat.requiresThinkingAsText ?? detected.requiresThinkingAsText,
|
||||
thinkingFormat: compat.thinkingFormat ?? detected.thinkingFormat,
|
||||
openRouterRouting: (compat.openRouterRouting as Record<string, unknown> | undefined) ?? {},
|
||||
vercelGatewayRouting:
|
||||
(compat.vercelGatewayRouting as Record<string, unknown> | undefined) ??
|
||||
detected.vercelGatewayRouting,
|
||||
supportsStrictMode: compat.supportsStrictMode ?? detected.supportsStrictMode,
|
||||
supportsPromptCacheKey: compat.supportsPromptCacheKey === true,
|
||||
supportsLongCacheRetention: compat.supportsLongCacheRetention !== false,
|
||||
requiresStringContent: compat.requiresStringContent ?? false,
|
||||
strictMessageKeys: compat.strictMessageKeys === true,
|
||||
visibleReasoningDetailTypes:
|
||||
compat.visibleReasoningDetailTypes ?? detected.visibleReasoningDetailTypes,
|
||||
requiresReasoningContentOnAssistantMessages:
|
||||
compat.requiresReasoningContentOnAssistantMessages ??
|
||||
detected.requiresReasoningContentOnAssistantMessages,
|
||||
requiresNonEmptyUserOrAssistantMessage: detected.requiresNonEmptyUserOrAssistantMessage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { Api, Model, OpenAICompletionsCompat, Usage } from "@openclaw/llm-core";
|
||||
import { getAiTransportHost } from "../host.js";
|
||||
/** Shared options, usage shape, cache identity, ordering, and stream scheduling for OpenAI APIs. */
|
||||
import {
|
||||
clampOpenAIPromptCacheKey,
|
||||
type OpenAICompletionsToolChoice,
|
||||
type OpenAIReasoningEffort,
|
||||
} from "../internal/openai.js";
|
||||
|
||||
const MODEL_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12;
|
||||
const MODEL_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64;
|
||||
|
||||
export const GEMINI_THOUGHT_SIGNATURE_VALIDATOR_SKIP = "skip_thought_signature_validator";
|
||||
export const log = {
|
||||
debug(message: string, data?: Record<string, unknown>) {
|
||||
getAiTransportHost().logDebug("openai-transport", () => ({ message, data }));
|
||||
},
|
||||
info(message: string, data?: Record<string, unknown>) {
|
||||
getAiTransportHost().logInfo("openai-transport", message, data);
|
||||
},
|
||||
warn(message: string, data?: Record<string, unknown>) {
|
||||
getAiTransportHost().logWarn("openai-transport", message, data);
|
||||
},
|
||||
};
|
||||
|
||||
export type BaseOpenAIStreamOptions = {
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
maxTokens?: number;
|
||||
stop?: string[];
|
||||
signal?: AbortSignal;
|
||||
apiKey?: string;
|
||||
cacheRetention?: "none" | "short" | "long";
|
||||
sessionId?: string;
|
||||
promptCacheKey?: string;
|
||||
authProfileId?: string;
|
||||
onPayload?: (payload: unknown, model: Model) => unknown;
|
||||
headers?: Record<string, string>;
|
||||
firstEventTimeoutMs?: number;
|
||||
onFirstEventTimeout?: (reason: Error) => void;
|
||||
openclawCodeModeToolSurface?: boolean;
|
||||
responseFormat?: Record<string, unknown>;
|
||||
frequencyPenalty?: number;
|
||||
presencePenalty?: number;
|
||||
seed?: number;
|
||||
};
|
||||
|
||||
export type OpenAICompletionsOptions = BaseOpenAIStreamOptions & {
|
||||
toolChoice?: OpenAICompletionsToolChoice;
|
||||
reasoning?: OpenAIReasoningEffort;
|
||||
reasoningEffort?: OpenAIReasoningEffort;
|
||||
};
|
||||
|
||||
type OpenAIModeCompatInput = Omit<OpenAICompletionsCompat, "thinkingFormat"> & {
|
||||
thinkingFormat?: string;
|
||||
requiresStringContent?: boolean;
|
||||
strictMessageKeys?: boolean;
|
||||
unsupportedToolSchemaKeywords?: unknown;
|
||||
omitEmptyArrayItems?: unknown;
|
||||
visibleReasoningDetailTypes?: string[];
|
||||
};
|
||||
|
||||
export type OpenAIModeModel = Omit<Model, "compat"> & {
|
||||
compat?: OpenAIModeCompatInput | null;
|
||||
};
|
||||
|
||||
export type MutableAssistantOutput = {
|
||||
role: "assistant";
|
||||
content: Array<Record<string, unknown>>;
|
||||
api: Api;
|
||||
provider: string;
|
||||
model: string;
|
||||
usage: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
reasoningTokens?: number;
|
||||
totalTokens: number;
|
||||
cost: Usage["cost"];
|
||||
};
|
||||
stopReason: string;
|
||||
timestamp: number;
|
||||
responseId?: string;
|
||||
errorMessage?: string;
|
||||
errorCode?: string;
|
||||
errorType?: string;
|
||||
errorBody?: string;
|
||||
};
|
||||
|
||||
type ModelStreamCooperativeScheduler = {
|
||||
afterEvent: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function throwIfModelStreamAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
}
|
||||
|
||||
export function createModelStreamCooperativeScheduler(
|
||||
signal?: AbortSignal,
|
||||
): ModelStreamCooperativeScheduler {
|
||||
let lastYieldedAt = Date.now();
|
||||
let eventsSinceYield = 0;
|
||||
return {
|
||||
async afterEvent() {
|
||||
throwIfModelStreamAborted(signal);
|
||||
eventsSinceYield += 1;
|
||||
const now = Date.now();
|
||||
if (
|
||||
eventsSinceYield < MODEL_STREAM_COOPERATIVE_YIELD_MAX_EVENTS &&
|
||||
now - lastYieldedAt < MODEL_STREAM_COOPERATIVE_YIELD_INTERVAL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
eventsSinceYield = 0;
|
||||
lastYieldedAt = now;
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
throwIfModelStreamAborted(signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCacheRetention(
|
||||
cacheRetention: string | undefined,
|
||||
): "short" | "long" | "none" {
|
||||
if (cacheRetention === "short" || cacheRetention === "long" || cacheRetention === "none") {
|
||||
return cacheRetention;
|
||||
}
|
||||
if (typeof process !== "undefined" && process.env.OPENCLAW_CACHE_RETENTION === "long") {
|
||||
return "long";
|
||||
}
|
||||
return "short";
|
||||
}
|
||||
|
||||
export function resolvePromptCacheKey(
|
||||
options: Pick<BaseOpenAIStreamOptions, "promptCacheKey" | "sessionId"> | undefined,
|
||||
cacheRetention: "short" | "long" | "none",
|
||||
): string | undefined {
|
||||
if (cacheRetention === "none") {
|
||||
return undefined;
|
||||
}
|
||||
return clampOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId);
|
||||
}
|
||||
|
||||
function compareTransportToolText(left: string | undefined, right: string | undefined): number {
|
||||
const leftText = left ?? "";
|
||||
const rightText = right ?? "";
|
||||
if (leftText < rightText) {
|
||||
return -1;
|
||||
}
|
||||
if (leftText > rightText) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sortTransportToolsByName<T extends { name?: string; description?: string }>(
|
||||
tools: readonly T[],
|
||||
): T[] {
|
||||
return tools.toSorted(
|
||||
(left, right) =>
|
||||
compareTransportToolText(left.name, right.name) ||
|
||||
compareTransportToolText(left.description, right.description),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Verifies transport-aware model stream aliases and fail-closed boundaries.
|
||||
import type { Api, Model } from "@openclaw/llm-core";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { configureAiTransportHost, getAiTransportHost } from "../host.js";
|
||||
import {
|
||||
buildTransportAwareSimpleStreamFn,
|
||||
createBoundaryAwareStreamFnForModel,
|
||||
createOpenClawTransportStreamFnForModel,
|
||||
createTransportAwareStreamFnForModel,
|
||||
prepareTransportAwareSimpleModel,
|
||||
resolveTransportAwareSimpleApi,
|
||||
} from "./provider-transport-stream.js";
|
||||
|
||||
const managedTransportModels = new WeakSet<object>();
|
||||
const initialHost = getAiTransportHost();
|
||||
|
||||
function attachManagedTransport<TModel extends object>(model: TModel, config: unknown): TModel {
|
||||
if (!config) {
|
||||
return model;
|
||||
}
|
||||
const attached = { ...model };
|
||||
managedTransportModels.add(attached);
|
||||
return attached;
|
||||
}
|
||||
|
||||
const attachModelProviderRequestTransport = attachManagedTransport;
|
||||
const attachModelProviderLocalService = attachManagedTransport;
|
||||
|
||||
beforeAll(() => {
|
||||
configureAiTransportHost({
|
||||
requiresManagedTransport: (model) => managedTransportModels.has(model),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
configureAiTransportHost(initialHost);
|
||||
});
|
||||
|
||||
function buildModel<TApi extends Api>(
|
||||
api: TApi,
|
||||
params: {
|
||||
id: string;
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
},
|
||||
): Model<TApi> {
|
||||
// Minimal model rows keep the transport matrix focused on api/provider/baseUrl.
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.id,
|
||||
api,
|
||||
provider: params.provider,
|
||||
baseUrl: params.baseUrl,
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8_192,
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider transport stream contracts", () => {
|
||||
it("covers the supported transport api alias matrix", () => {
|
||||
// Supported APIs can be projected to OpenClaw transport aliases when needed.
|
||||
const cases = [
|
||||
{
|
||||
api: "openai-responses" as const,
|
||||
provider: "openai",
|
||||
id: "gpt-5.4",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
alias: "openclaw-openai-responses-transport",
|
||||
},
|
||||
{
|
||||
api: "openai-chatgpt-responses" as const,
|
||||
provider: "openai",
|
||||
id: "codex-mini-latest",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
alias: "openclaw-openai-responses-transport",
|
||||
},
|
||||
{
|
||||
api: "openai-completions" as const,
|
||||
provider: "xai",
|
||||
id: "grok-4",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
alias: "openclaw-openai-completions-transport",
|
||||
},
|
||||
{
|
||||
api: "azure-openai-responses" as const,
|
||||
provider: "azure-openai-responses",
|
||||
id: "gpt-5.4",
|
||||
baseUrl: "https://example.openai.azure.com/openai/v1",
|
||||
alias: "openclaw-azure-openai-responses-transport",
|
||||
},
|
||||
{
|
||||
api: "anthropic-messages" as const,
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4.6",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
alias: "openclaw-anthropic-messages-transport",
|
||||
},
|
||||
{
|
||||
api: "google-generative-ai" as const,
|
||||
provider: "google",
|
||||
id: "gemini-3.1-pro-preview",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
alias: "openclaw-google-generative-ai-transport",
|
||||
providerOwnedRuntime: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const model = attachModelProviderRequestTransport(
|
||||
buildModel(testCase.api, {
|
||||
id: testCase.id,
|
||||
provider: testCase.provider,
|
||||
baseUrl: testCase.baseUrl,
|
||||
}),
|
||||
{
|
||||
proxy: {
|
||||
mode: "explicit-proxy",
|
||||
url: "http://proxy.internal:8443",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveTransportAwareSimpleApi(testCase.api)).toBe(testCase.alias);
|
||||
if (testCase.providerOwnedRuntime) {
|
||||
continue;
|
||||
}
|
||||
expect(createBoundaryAwareStreamFnForModel(model)).toBeTypeOf("function");
|
||||
expect(createTransportAwareStreamFnForModel(model)).toBeTypeOf("function");
|
||||
expect(buildTransportAwareSimpleStreamFn(model)).toBeTypeOf("function");
|
||||
const preparedModel = prepareTransportAwareSimpleModel(model);
|
||||
expect(preparedModel.api).toBe(testCase.alias);
|
||||
expect(preparedModel.provider).toBe(testCase.provider);
|
||||
expect(preparedModel.id).toBe(testCase.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed when unsupported apis carry transport overrides", () => {
|
||||
const model = attachModelProviderRequestTransport(
|
||||
buildModel("ollama", {
|
||||
id: "qwen3:32b",
|
||||
provider: "ollama",
|
||||
baseUrl: "http://localhost:11434",
|
||||
}),
|
||||
{
|
||||
proxy: {
|
||||
mode: "explicit-proxy",
|
||||
url: "http://proxy.internal:8443",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveTransportAwareSimpleApi(model.api)).toBeUndefined();
|
||||
expect(createBoundaryAwareStreamFnForModel(model)).toBeUndefined();
|
||||
expect(() => createTransportAwareStreamFnForModel(model)).toThrow(
|
||||
'Model-provider request.proxy/request.tls/localService is not yet supported for api "ollama"',
|
||||
);
|
||||
expect(() => buildTransportAwareSimpleStreamFn(model)).toThrow(
|
||||
'Model-provider request.proxy/request.tls/localService is not yet supported for api "ollama"',
|
||||
);
|
||||
expect(() => prepareTransportAwareSimpleModel(model)).toThrow(
|
||||
'Model-provider request.proxy/request.tls/localService is not yet supported for api "ollama"',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps unsupported apis unchanged when no transport overrides are attached", () => {
|
||||
const model = buildModel("ollama", {
|
||||
id: "qwen3:32b",
|
||||
provider: "ollama",
|
||||
baseUrl: "http://localhost:11434",
|
||||
});
|
||||
|
||||
expect(createTransportAwareStreamFnForModel(model)).toBeUndefined();
|
||||
expect(buildTransportAwareSimpleStreamFn(model)).toBeUndefined();
|
||||
expect(prepareTransportAwareSimpleModel(model)).toBe(model);
|
||||
});
|
||||
|
||||
it("keeps OpenAI API-key default streams on OpenClaw transport", () => {
|
||||
const cases = [
|
||||
buildModel("openai-responses", {
|
||||
id: "gpt-5.4",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
}),
|
||||
buildModel("openai-completions", {
|
||||
id: "gpt-4o",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
}),
|
||||
] as const;
|
||||
|
||||
for (const model of cases) {
|
||||
expect(createBoundaryAwareStreamFnForModel(model)).toBeTypeOf("function");
|
||||
expect(createOpenClawTransportStreamFnForModel(model)).toBeTypeOf("function");
|
||||
expect(createTransportAwareStreamFnForModel(model)).toBeUndefined();
|
||||
expect(buildTransportAwareSimpleStreamFn(model)).toBeUndefined();
|
||||
expect(prepareTransportAwareSimpleModel(model)).toBe(model);
|
||||
}
|
||||
});
|
||||
|
||||
it("routes localService models through the OpenClaw simple-completion transport", () => {
|
||||
const model = attachModelProviderLocalService(
|
||||
buildModel("openai-completions", {
|
||||
id: "google/gemma-4-E2B-it",
|
||||
provider: "inferrs",
|
||||
baseUrl: "http://127.0.0.1:8080/v1",
|
||||
}),
|
||||
{
|
||||
command: "/usr/local/bin/inferrs",
|
||||
args: ["serve", "google/gemma-4-E2B-it"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(createTransportAwareStreamFnForModel(model)).toBeTypeOf("function");
|
||||
expect(buildTransportAwareSimpleStreamFn(model)).toBeTypeOf("function");
|
||||
const preparedModel = prepareTransportAwareSimpleModel(model);
|
||||
expect(preparedModel.api).toBe("openclaw-openai-completions-transport");
|
||||
expect(preparedModel.provider).toBe("inferrs");
|
||||
expect(preparedModel.id).toBe("google/gemma-4-E2B-it");
|
||||
});
|
||||
|
||||
it("keeps Codex defaults on the OpenClaw transport until OpenClaw preserves attribution", () => {
|
||||
const model = buildModel("openai-chatgpt-responses", {
|
||||
id: "gpt-5.4",
|
||||
provider: "openai",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
});
|
||||
|
||||
expect(createBoundaryAwareStreamFnForModel(model)).toBeTypeOf("function");
|
||||
expect(createTransportAwareStreamFnForModel(model)).toBeUndefined();
|
||||
expect(buildTransportAwareSimpleStreamFn(model)).toBeUndefined();
|
||||
expect(prepareTransportAwareSimpleModel(model)).toBe(model);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Transport-aware stream factory selection.
|
||||
*
|
||||
* Routes models that need OpenClaw-managed proxy/TLS/local-service semantics onto built-in transport implementations.
|
||||
*/
|
||||
import type { Api, Model, StreamFn } from "@openclaw/llm-core";
|
||||
import { getAiTransportHost } from "../host.js";
|
||||
import { createAnthropicMessagesTransportStreamFn } from "./anthropic-transport-stream.js";
|
||||
import { createOpenAICompletionsTransportStreamFn } from "./openai-completions-transport.js";
|
||||
import {
|
||||
createAzureOpenAIResponsesTransportStreamFn,
|
||||
createOpenAIResponsesTransportStreamFn,
|
||||
} from "./openai-responses-transport.js";
|
||||
|
||||
const SUPPORTED_TRANSPORT_APIS = new Set<Api>([
|
||||
"openai-responses",
|
||||
"openai-chatgpt-responses",
|
||||
"openai-completions",
|
||||
"azure-openai-responses",
|
||||
"anthropic-messages",
|
||||
"google-generative-ai",
|
||||
]);
|
||||
|
||||
const SIMPLE_TRANSPORT_API_ALIAS: Record<string, Api> = {
|
||||
"openai-responses": "openclaw-openai-responses-transport",
|
||||
"openai-chatgpt-responses": "openclaw-openai-responses-transport",
|
||||
"openai-completions": "openclaw-openai-completions-transport",
|
||||
"azure-openai-responses": "openclaw-azure-openai-responses-transport",
|
||||
"anthropic-messages": "openclaw-anthropic-messages-transport",
|
||||
"google-generative-ai": "openclaw-google-generative-ai-transport",
|
||||
};
|
||||
|
||||
type ProviderTransportStreamContext = {
|
||||
cfg?: unknown;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
function createProviderOwnedGoogleTransportStreamFn(
|
||||
model: Model,
|
||||
ctx?: ProviderTransportStreamContext,
|
||||
): StreamFn | undefined {
|
||||
return (
|
||||
getAiTransportHost().plugin.resolveProviderStream({
|
||||
provider: model.provider,
|
||||
config: ctx?.cfg,
|
||||
workspaceDir: ctx?.workspaceDir,
|
||||
env: ctx?.env,
|
||||
context: {
|
||||
config: ctx?.cfg,
|
||||
agentDir: ctx?.agentDir,
|
||||
workspaceDir: ctx?.workspaceDir,
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
model,
|
||||
},
|
||||
}) ??
|
||||
getAiTransportHost().plugin.resolveProviderStream({
|
||||
provider: "google",
|
||||
config: ctx?.cfg,
|
||||
workspaceDir: ctx?.workspaceDir,
|
||||
env: ctx?.env,
|
||||
context: {
|
||||
config: ctx?.cfg,
|
||||
agentDir: ctx?.agentDir,
|
||||
workspaceDir: ctx?.workspaceDir,
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
model,
|
||||
},
|
||||
}) ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function createSupportedTransportStreamFn(
|
||||
model: Model,
|
||||
ctx?: ProviderTransportStreamContext,
|
||||
): StreamFn | undefined {
|
||||
switch (model.api) {
|
||||
case "openai-responses":
|
||||
case "openai-chatgpt-responses":
|
||||
return createOpenAIResponsesTransportStreamFn();
|
||||
case "openai-completions":
|
||||
return createOpenAICompletionsTransportStreamFn();
|
||||
case "azure-openai-responses":
|
||||
return createAzureOpenAIResponsesTransportStreamFn();
|
||||
case "anthropic-messages":
|
||||
return createAnthropicMessagesTransportStreamFn();
|
||||
case "google-generative-ai":
|
||||
return createProviderOwnedGoogleTransportStreamFn(model, ctx);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function hasOpenClawTransportRequirement(model: Model): boolean {
|
||||
return getAiTransportHost().requiresManagedTransport(model);
|
||||
}
|
||||
|
||||
/** Returns whether OpenClaw has a managed transport implementation for this API. */
|
||||
function isTransportAwareApiSupported(api: Api): boolean {
|
||||
return SUPPORTED_TRANSPORT_APIS.has(api);
|
||||
}
|
||||
|
||||
/** Maps public model APIs to the internal transport API id used by simple runtime dispatch. */
|
||||
export function resolveTransportAwareSimpleApi(api: Api): Api | undefined {
|
||||
return SIMPLE_TRANSPORT_API_ALIAS[api];
|
||||
}
|
||||
|
||||
/** Creates a managed transport stream only when request overrides require it. */
|
||||
export function createTransportAwareStreamFnForModel(
|
||||
model: Model,
|
||||
ctx?: ProviderTransportStreamContext,
|
||||
): StreamFn | undefined {
|
||||
if (!hasOpenClawTransportRequirement(model)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isTransportAwareApiSupported(model.api)) {
|
||||
throw new Error(
|
||||
`Model-provider request.proxy/request.tls/localService is not yet supported for api "${model.api}"`,
|
||||
);
|
||||
}
|
||||
return createSupportedTransportStreamFn(model, ctx);
|
||||
}
|
||||
|
||||
/** Creates a managed OpenClaw transport stream for explicit fallback/runtime callers. */
|
||||
export function createOpenClawTransportStreamFnForModel(
|
||||
model: Model,
|
||||
ctx?: ProviderTransportStreamContext,
|
||||
): StreamFn | undefined {
|
||||
// Explicit fallback callers use this when they need OpenClaw's HTTP
|
||||
// transport semantics regardless of the default embedded-runner strategy.
|
||||
// Native OpenAI HTTP still depends on this path for strict tool shaping,
|
||||
// attribution, cache-boundary stripping, and runtime credential injection.
|
||||
if (!isTransportAwareApiSupported(model.api)) {
|
||||
return undefined;
|
||||
}
|
||||
return createSupportedTransportStreamFn(model, ctx);
|
||||
}
|
||||
|
||||
export function createBoundaryAwareStreamFnForModel(
|
||||
model: Model,
|
||||
ctx?: ProviderTransportStreamContext,
|
||||
): StreamFn | undefined {
|
||||
// Default embedded-runner fallback. Keep OpenAI-family APIs here while native
|
||||
// HTTP streams preserve the same OpenClaw request contract.
|
||||
if (!isTransportAwareApiSupported(model.api)) {
|
||||
return undefined;
|
||||
}
|
||||
return createSupportedTransportStreamFn(model, ctx);
|
||||
}
|
||||
|
||||
export function prepareTransportAwareSimpleModel<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
ctx?: ProviderTransportStreamContext,
|
||||
): Model {
|
||||
const streamFn = createTransportAwareStreamFnForModel(model as Model, ctx);
|
||||
const alias = resolveTransportAwareSimpleApi(model.api);
|
||||
if (!streamFn || !alias) {
|
||||
return model;
|
||||
}
|
||||
return {
|
||||
...model,
|
||||
api: alias,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTransportAwareSimpleStreamFn(
|
||||
model: Model,
|
||||
ctx?: ProviderTransportStreamContext,
|
||||
): StreamFn | undefined {
|
||||
return createTransportAwareStreamFnForModel(model, ctx);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Sanitizes OpenAI Responses payloads before transport. Invalid inline images
|
||||
* are replaced with text placeholders so the request remains valid and
|
||||
* auditable.
|
||||
*/
|
||||
import { sanitizeInlineImageDataUrl as sanitizeSharedInlineImageDataUrl } from "@openclaw/media-core/inline-image-data-url";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
|
||||
const IMAGE_OMITTED_TEXT = "omitted image payload: invalid inline image data";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function invalidSnakeImage(): JsonRecord {
|
||||
return { type: "input_text", text: `[${IMAGE_OMITTED_TEXT}]` };
|
||||
}
|
||||
|
||||
function sanitizeValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sanitizeValue);
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value.type === "input_image" && typeof value.image_url === "string") {
|
||||
const imageUrl = sanitizeSharedInlineImageDataUrl(value.image_url);
|
||||
return imageUrl ? { ...value, image_url: imageUrl } : invalidSnakeImage();
|
||||
}
|
||||
|
||||
const next: JsonRecord = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
next[key] = sanitizeValue(child);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Sanitize inline image fields inside a Responses API payload. */
|
||||
export function sanitizeResponsesImagePayload<T extends Record<string, unknown>>(params: T): T {
|
||||
if (!Array.isArray(params.input)) {
|
||||
return params;
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
input: sanitizeValue(params.input),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import type { Model, StreamFn } from "@openclaw/llm-core";
|
||||
// Simple completion transport tests cover provider-specific stream alias
|
||||
// selection before the generic completion helper invokes the LLM layer.
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiRegistry, type ApiRegistry } from "../api-registry.js";
|
||||
import { configureAiTransportHost, getAiTransportHost } from "../host.js";
|
||||
|
||||
const createAnthropicVertexStreamFnForModel = vi.fn();
|
||||
const ensureCustomApiRegistered = vi.fn();
|
||||
const resolveProviderStreamFn = vi.fn();
|
||||
const wrapProviderSimpleCompletionStreamFn = vi.fn();
|
||||
const buildTransportAwareSimpleStreamFn = vi.fn();
|
||||
const createOpenClawTransportStreamFnForModel = vi.fn();
|
||||
const createTransportAwareStreamFnForModel = vi.fn();
|
||||
const prepareTransportAwareSimpleModel = vi.fn();
|
||||
const resolveTransportAwareSimpleApi = vi.fn();
|
||||
const prepareGoogleSimpleCompletionModel = vi.fn((_registry: unknown, model: unknown) => model);
|
||||
const pluginStreamFn = vi.fn(() => "plugin-stream-result" as never);
|
||||
const TEST_SECRET = "ollama-provider-secret";
|
||||
const TEST_SECRET_SENTINEL = "test-secret-sentinel";
|
||||
const initialHost = getAiTransportHost();
|
||||
|
||||
vi.mock("./provider-transport-stream.js", () => ({
|
||||
buildTransportAwareSimpleStreamFn,
|
||||
createOpenClawTransportStreamFnForModel,
|
||||
createTransportAwareStreamFnForModel,
|
||||
prepareTransportAwareSimpleModel,
|
||||
resolveTransportAwareSimpleApi,
|
||||
}));
|
||||
|
||||
let prepareModelForSimpleCompletionImpl: typeof import("./simple-completion-transport.js").prepareModelForSimpleCompletion;
|
||||
let apiRegistry: ApiRegistry;
|
||||
const SIMPLE_COMPLETION_SOURCE_ID = "test:simple-completion-transport";
|
||||
|
||||
function prepareModelForSimpleCompletion(
|
||||
params: Omit<
|
||||
Parameters<
|
||||
typeof import("./simple-completion-transport.js").prepareModelForSimpleCompletion
|
||||
>[0],
|
||||
"apiRegistry"
|
||||
>,
|
||||
) {
|
||||
return prepareModelForSimpleCompletionImpl({ ...params, apiRegistry });
|
||||
}
|
||||
|
||||
describe("prepareModelForSimpleCompletion", () => {
|
||||
beforeAll(async () => {
|
||||
// Dynamic import lets the mocked transport/provider modules settle before
|
||||
// the unit under test captures custom stream registration helpers.
|
||||
({ prepareModelForSimpleCompletion: prepareModelForSimpleCompletionImpl } =
|
||||
await import("./simple-completion-transport.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
apiRegistry = createApiRegistry();
|
||||
createAnthropicVertexStreamFnForModel.mockReset();
|
||||
ensureCustomApiRegistered.mockReset();
|
||||
resolveProviderStreamFn.mockReset();
|
||||
pluginStreamFn.mockClear();
|
||||
wrapProviderSimpleCompletionStreamFn.mockReset();
|
||||
buildTransportAwareSimpleStreamFn.mockReset();
|
||||
createOpenClawTransportStreamFnForModel.mockReset();
|
||||
createTransportAwareStreamFnForModel.mockReset();
|
||||
prepareTransportAwareSimpleModel.mockReset();
|
||||
resolveTransportAwareSimpleApi.mockReset();
|
||||
prepareGoogleSimpleCompletionModel.mockReset();
|
||||
createAnthropicVertexStreamFnForModel.mockReturnValue("vertex-stream");
|
||||
resolveProviderStreamFn.mockReturnValue(pluginStreamFn);
|
||||
wrapProviderSimpleCompletionStreamFn.mockReturnValue(undefined);
|
||||
buildTransportAwareSimpleStreamFn.mockReturnValue(undefined);
|
||||
createOpenClawTransportStreamFnForModel.mockReturnValue(undefined);
|
||||
createTransportAwareStreamFnForModel.mockReturnValue(undefined);
|
||||
prepareTransportAwareSimpleModel.mockImplementation((model) => model);
|
||||
resolveTransportAwareSimpleApi.mockReturnValue(undefined);
|
||||
prepareGoogleSimpleCompletionModel.mockImplementation((_registry, model) => model);
|
||||
configureAiTransportHost({
|
||||
plugin: {
|
||||
resolveProviderStream: resolveProviderStreamFn,
|
||||
resolveTransportTurnState: () => undefined,
|
||||
wrapSimpleCompletionStream: wrapProviderSimpleCompletionStreamFn,
|
||||
createAnthropicVertexStream: createAnthropicVertexStreamFnForModel,
|
||||
},
|
||||
registerCustomApi: ensureCustomApiRegistered,
|
||||
prepareGoogleSimpleCompletionModel: prepareGoogleSimpleCompletionModel as (
|
||||
registry: ApiRegistry,
|
||||
model: Model,
|
||||
) => Model,
|
||||
resolveSecretSentinel: (value) => value.replaceAll(TEST_SECRET_SENTINEL, TEST_SECRET),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
configureAiTransportHost(initialHost);
|
||||
});
|
||||
|
||||
it("routes provider-owned simple-completion wrappers through an internal API alias", () => {
|
||||
const sourceApi = "moonshot-simple-source";
|
||||
const sourceResult = { source: true };
|
||||
let capturedApi: string | undefined;
|
||||
apiRegistry.registerApiProvider(
|
||||
{
|
||||
api: sourceApi,
|
||||
stream: () => sourceResult as never,
|
||||
streamSimple: (runtimeModel) => {
|
||||
capturedApi = runtimeModel.api;
|
||||
return sourceResult as never;
|
||||
},
|
||||
},
|
||||
SIMPLE_COMPLETION_SOURCE_ID,
|
||||
);
|
||||
wrapProviderSimpleCompletionStreamFn.mockImplementationOnce(
|
||||
({ context }) =>
|
||||
(
|
||||
model: Parameters<StreamFn>[0],
|
||||
streamContext: Parameters<StreamFn>[1],
|
||||
options: Parameters<StreamFn>[2],
|
||||
) =>
|
||||
context.streamFn(model, streamContext, options),
|
||||
);
|
||||
const model: Model = {
|
||||
id: "kimi-k2.7-code",
|
||||
name: "Kimi K2.7 Code",
|
||||
api: sourceApi,
|
||||
provider: "moonshot",
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 },
|
||||
contextWindow: 262_144,
|
||||
maxTokens: 262_144,
|
||||
};
|
||||
|
||||
const result = prepareModelForSimpleCompletion({ model });
|
||||
|
||||
expect(wrapProviderSimpleCompletionStreamFn).toHaveBeenCalledTimes(1);
|
||||
expect(wrapProviderSimpleCompletionStreamFn.mock.results[0]?.value).toBeTypeOf("function");
|
||||
expect(result.api).toBe(
|
||||
"openclaw-provider-simple:moonshot:kimi-k2.7-code:moonshot-simple-source:https%3A%2F%2Fapi.moonshot.ai%2Fv1",
|
||||
);
|
||||
expect(wrapProviderSimpleCompletionStreamFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "moonshot",
|
||||
context: expect.objectContaining({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.7-code",
|
||||
model,
|
||||
streamFn: expect.any(Function),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const registeredStream = ensureCustomApiRegistered.mock.calls.at(-1)?.[2];
|
||||
expect(registeredStream).toBeTypeOf("function");
|
||||
const stream = registeredStream(result, { messages: [] }, {});
|
||||
expect(stream).toBe(sourceResult);
|
||||
expect(stream).not.toBeInstanceOf(Promise);
|
||||
expect(capturedApi).toBe(sourceApi);
|
||||
});
|
||||
|
||||
it("registers the configured Ollama transport and keeps the original api", () => {
|
||||
const secret = TEST_SECRET;
|
||||
const sentinel = TEST_SECRET_SENTINEL;
|
||||
const model: Model<"ollama"> = {
|
||||
id: "llama3",
|
||||
name: "Llama 3",
|
||||
api: "ollama",
|
||||
provider: "ollama",
|
||||
baseUrl: "http://localhost:11434",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 4096,
|
||||
headers: { Authorization: `Bearer ${sentinel}` },
|
||||
};
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://remote-ollama:11434",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = prepareModelForSimpleCompletion({
|
||||
model,
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(resolveProviderStreamFn).toHaveBeenCalledTimes(1);
|
||||
const [request] = resolveProviderStreamFn.mock.calls.at(0) as [
|
||||
{
|
||||
provider?: unknown;
|
||||
config?: unknown;
|
||||
context?: { provider?: unknown; modelId?: unknown; model?: unknown };
|
||||
},
|
||||
];
|
||||
expect(request.provider).toBe("ollama");
|
||||
expect(request.config).toBe(cfg);
|
||||
expect(request.context?.provider).toBe("ollama");
|
||||
expect(request.context?.modelId).toBe("llama3");
|
||||
expect(request.context?.model).toEqual({
|
||||
...model,
|
||||
headers: { Authorization: `Bearer ${secret}` },
|
||||
});
|
||||
expect(ensureCustomApiRegistered).toHaveBeenCalledWith(
|
||||
apiRegistry,
|
||||
"ollama",
|
||||
expect.any(Function),
|
||||
);
|
||||
const registeredStream = ensureCustomApiRegistered.mock.calls[0]?.[2] as StreamFn;
|
||||
void registeredStream(
|
||||
{ ...model, headers: { Authorization: `Bearer ${sentinel}` } } as never,
|
||||
{} as never,
|
||||
{ apiKey: sentinel, headers: { "X-Managed": `Bearer ${sentinel}` } } as never,
|
||||
);
|
||||
expect(pluginStreamFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ headers: { Authorization: `Bearer ${secret}` } }),
|
||||
{},
|
||||
{ apiKey: secret, headers: { "X-Managed": `Bearer ${secret}` } },
|
||||
);
|
||||
expect(result).toBe(model);
|
||||
});
|
||||
|
||||
it("uses a custom api alias for Anthropic Vertex simple completions", () => {
|
||||
const model: Model<"anthropic-messages"> = {
|
||||
id: "claude-sonnet",
|
||||
name: "Claude Sonnet",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic-vertex",
|
||||
baseUrl: "https://us-central1-aiplatform.googleapis.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
|
||||
resolveProviderStreamFn.mockReturnValueOnce(undefined);
|
||||
|
||||
const result = prepareModelForSimpleCompletion({ model });
|
||||
|
||||
expect(createAnthropicVertexStreamFnForModel).toHaveBeenCalledWith(model);
|
||||
expect(ensureCustomApiRegistered).toHaveBeenCalledWith(
|
||||
apiRegistry,
|
||||
"openclaw-anthropic-vertex-simple:https%3A%2F%2Fus-central1-aiplatform.googleapis.com",
|
||||
"vertex-stream",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
...model,
|
||||
api: "openclaw-anthropic-vertex-simple:https%3A%2F%2Fus-central1-aiplatform.googleapis.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a transport-aware custom api alias when llm request transport overrides are present", () => {
|
||||
const model: Model<"openai-responses"> = {
|
||||
id: "gpt-5",
|
||||
name: "GPT-5",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
|
||||
resolveProviderStreamFn.mockReturnValueOnce(undefined);
|
||||
buildTransportAwareSimpleStreamFn.mockReturnValueOnce("transport-stream");
|
||||
prepareTransportAwareSimpleModel.mockReturnValueOnce({
|
||||
...model,
|
||||
api: "openclaw-openai-responses-transport",
|
||||
});
|
||||
|
||||
const result = prepareModelForSimpleCompletion({ model });
|
||||
|
||||
expect(prepareTransportAwareSimpleModel).toHaveBeenCalledWith(model, { cfg: undefined });
|
||||
expect(buildTransportAwareSimpleStreamFn).toHaveBeenCalledWith(model, { cfg: undefined });
|
||||
expect(ensureCustomApiRegistered).toHaveBeenCalledWith(
|
||||
apiRegistry,
|
||||
"openclaw-openai-responses-transport",
|
||||
"transport-stream",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
...model,
|
||||
api: "openclaw-openai-responses-transport",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the Google simple-completion sanitizer alias after transport checks pass through", () => {
|
||||
const model: Model<"google-generative-ai"> = {
|
||||
id: "gemini-flash-latest",
|
||||
name: "Gemini Flash Latest",
|
||||
api: "google-generative-ai",
|
||||
provider: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 8192,
|
||||
headers: {},
|
||||
};
|
||||
prepareGoogleSimpleCompletionModel.mockImplementationOnce((_registry: unknown, m: unknown) => ({
|
||||
...(m as Model<"google-generative-ai">),
|
||||
api: "openclaw-google-generative-ai-simple",
|
||||
}));
|
||||
resolveProviderStreamFn.mockReturnValueOnce(undefined);
|
||||
|
||||
const result = prepareModelForSimpleCompletion({ model });
|
||||
|
||||
expect(prepareTransportAwareSimpleModel).toHaveBeenCalledWith(model, { cfg: undefined });
|
||||
expect(prepareGoogleSimpleCompletionModel).toHaveBeenCalledWith(apiRegistry, model);
|
||||
expect(buildTransportAwareSimpleStreamFn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
...model,
|
||||
api: "openclaw-google-generative-ai-simple",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Google transport-aware models on the transport alias", () => {
|
||||
const model: Model<"google-generative-ai"> = {
|
||||
id: "gemini-flash-latest",
|
||||
name: "Gemini Flash Latest",
|
||||
api: "google-generative-ai",
|
||||
provider: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 8192,
|
||||
headers: {},
|
||||
};
|
||||
|
||||
const transportModel = {
|
||||
...model,
|
||||
api: "openclaw-google-generative-ai-transport",
|
||||
};
|
||||
resolveProviderStreamFn.mockReturnValueOnce(undefined);
|
||||
buildTransportAwareSimpleStreamFn.mockReturnValueOnce("google-transport-stream");
|
||||
prepareTransportAwareSimpleModel.mockReturnValueOnce(transportModel);
|
||||
|
||||
const result = prepareModelForSimpleCompletion({ model });
|
||||
|
||||
expect(buildTransportAwareSimpleStreamFn).toHaveBeenCalledWith(model, { cfg: undefined });
|
||||
expect(ensureCustomApiRegistered).toHaveBeenCalledWith(
|
||||
apiRegistry,
|
||||
"openclaw-google-generative-ai-transport",
|
||||
"google-transport-stream",
|
||||
);
|
||||
expect(prepareGoogleSimpleCompletionModel).not.toHaveBeenCalled();
|
||||
expect(result).toBe(transportModel);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["https://chatgpt.com/backend-api", "https://chatgpt.com/backend-api/codex"],
|
||||
["https://chatgpt.com/backend-api/v1", "https://chatgpt.com/backend-api/codex"],
|
||||
["https://chatgpt.com/backend-api/codex", "https://chatgpt.com/backend-api/codex"],
|
||||
["https://chatgpt.com/backend-api/codex/v1", "https://chatgpt.com/backend-api/codex"],
|
||||
["https://chatgpt.com/backend-api/codex/responses", "https://chatgpt.com/backend-api/codex"],
|
||||
["https://proxy.example.test/openai", "https://proxy.example.test/openai/codex"],
|
||||
[
|
||||
"https://proxy.example.test/openai/codex/responses",
|
||||
"https://proxy.example.test/openai/codex",
|
||||
],
|
||||
])(
|
||||
"uses OpenClaw transport for OpenAI Codex-response simple completions with baseUrl %s",
|
||||
(baseUrl, expectedBaseUrl) => {
|
||||
const model: Model<"openai-chatgpt-responses"> = {
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
api: "openai-chatgpt-responses",
|
||||
provider: "openai",
|
||||
baseUrl,
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
|
||||
resolveProviderStreamFn.mockReturnValueOnce(undefined);
|
||||
createOpenClawTransportStreamFnForModel.mockReturnValueOnce("codex-transport-stream");
|
||||
resolveTransportAwareSimpleApi.mockReturnValueOnce("openclaw-openai-responses-transport");
|
||||
|
||||
const result = prepareModelForSimpleCompletion({ model });
|
||||
|
||||
// ChatGPT/Codex response endpoints share the transport stream, but the
|
||||
// simple-completion API must normalize caller-supplied base URLs first.
|
||||
expect(createOpenClawTransportStreamFnForModel).toHaveBeenCalledWith(
|
||||
{
|
||||
...model,
|
||||
baseUrl: expectedBaseUrl,
|
||||
},
|
||||
{ cfg: undefined },
|
||||
);
|
||||
expect(ensureCustomApiRegistered).toHaveBeenCalledWith(
|
||||
apiRegistry,
|
||||
"openclaw-openai-responses-transport",
|
||||
"codex-transport-stream",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
...model,
|
||||
baseUrl: expectedBaseUrl,
|
||||
api: "openclaw-openai-responses-transport",
|
||||
});
|
||||
expect(prepareTransportAwareSimpleModel).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Simple completion transport preparation.
|
||||
*
|
||||
* Registers provider-specific stream functions and rewrites models that need OpenClaw-managed transport semantics.
|
||||
*/
|
||||
import type { Api, Model, StreamFn } from "@openclaw/llm-core";
|
||||
import type { ApiRegistry } from "../api-registry.js";
|
||||
import { getAiTransportHost, resolveAiTransportHeaderSentinels } from "../host.js";
|
||||
import {
|
||||
buildTransportAwareSimpleStreamFn,
|
||||
createOpenClawTransportStreamFnForModel,
|
||||
createTransportAwareStreamFnForModel,
|
||||
prepareTransportAwareSimpleModel,
|
||||
resolveTransportAwareSimpleApi,
|
||||
} from "./provider-transport-stream.js";
|
||||
|
||||
const PROVIDER_SIMPLE_COMPLETION_API_PREFIX = "openclaw-provider-simple:";
|
||||
|
||||
function resolveAnthropicVertexSimpleApi(baseUrl?: string): Api {
|
||||
const suffix = baseUrl?.trim() ? encodeURIComponent(baseUrl.trim()) : "default";
|
||||
return `openclaw-anthropic-vertex-simple:${suffix}`;
|
||||
}
|
||||
|
||||
export function normalizeCodexResponsesBaseUrlForOpenAISdk(baseUrl?: string): string {
|
||||
const normalized = baseUrl?.trim().replace(/\/+$/u, "") || "https://chatgpt.com/backend-api";
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
const path = parsed.pathname.replace(/\/+$/u, "").toLowerCase();
|
||||
if (
|
||||
parsed.hostname.toLowerCase() === "chatgpt.com" &&
|
||||
[
|
||||
"/backend-api",
|
||||
"/backend-api/v1",
|
||||
"/backend-api/codex",
|
||||
"/backend-api/codex/v1",
|
||||
"/backend-api/codex/responses",
|
||||
].includes(path)
|
||||
) {
|
||||
parsed.pathname = "/backend-api/codex";
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.toString().replace(/\/$/u, "");
|
||||
}
|
||||
} catch {
|
||||
// Keep non-URL custom values on the same suffix contract transport callers accept.
|
||||
}
|
||||
if (normalized.endsWith("/codex/responses")) {
|
||||
return normalized.slice(0, -"/responses".length);
|
||||
}
|
||||
if (normalized.endsWith("/codex")) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized}/codex`;
|
||||
}
|
||||
|
||||
function resolveProviderSimpleCompletionApi(model: Model): Api {
|
||||
const parts = [model.provider, model.id, model.api, model.baseUrl || "default"];
|
||||
return `${PROVIDER_SIMPLE_COMPLETION_API_PREFIX}${parts
|
||||
.map((part) => encodeURIComponent(part))
|
||||
.join(":")}`;
|
||||
}
|
||||
|
||||
function applyProviderSimpleCompletionWrapper(
|
||||
registry: ApiRegistry,
|
||||
model: Model,
|
||||
cfg?: unknown,
|
||||
): Model {
|
||||
if (model.api.startsWith(PROVIDER_SIMPLE_COMPLETION_API_PREFIX)) {
|
||||
return model;
|
||||
}
|
||||
const sourceProvider = registry.getApiProvider(model.api);
|
||||
if (!sourceProvider) {
|
||||
return model;
|
||||
}
|
||||
|
||||
const sourceApi = model.api;
|
||||
const sourceStreamFn: StreamFn = (runtimeModel, context, options) =>
|
||||
sourceProvider.streamSimple({ ...runtimeModel, api: sourceApi }, context, options);
|
||||
const streamFn = getAiTransportHost().plugin.wrapSimpleCompletionStream({
|
||||
provider: model.provider,
|
||||
config: cfg,
|
||||
context: {
|
||||
config: cfg,
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
model,
|
||||
streamFn: sourceStreamFn,
|
||||
},
|
||||
});
|
||||
if (!streamFn) {
|
||||
return model;
|
||||
}
|
||||
|
||||
const api = resolveProviderSimpleCompletionApi(model);
|
||||
getAiTransportHost().registerCustomApi(registry, api, streamFn);
|
||||
return { ...model, api };
|
||||
}
|
||||
|
||||
function prepareCodexSimpleTransportModel<TApi extends Api>(
|
||||
registry: ApiRegistry,
|
||||
model: Model<TApi>,
|
||||
cfg?: unknown,
|
||||
): Model | undefined {
|
||||
if (model.provider !== "openai" || model.api !== "openai-chatgpt-responses") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Static Codex provider catalogs intentionally omit credentials; the simple
|
||||
// completion path must use OpenClaw's transport so resolved request auth is applied.
|
||||
const transportModel = {
|
||||
...model,
|
||||
baseUrl: normalizeCodexResponsesBaseUrlForOpenAISdk(model.baseUrl),
|
||||
} as Model;
|
||||
const api = resolveTransportAwareSimpleApi(model.api);
|
||||
const streamFn = createOpenClawTransportStreamFnForModel(transportModel, { cfg });
|
||||
if (!api || !streamFn) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getAiTransportHost().registerCustomApi(registry, api, streamFn);
|
||||
return {
|
||||
...transportModel,
|
||||
api,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveModelHeaderSentinels<TApi extends Api>(model: Model<TApi>): Model<TApi> {
|
||||
const headers = resolveAiTransportHeaderSentinels(model.headers);
|
||||
return headers === model.headers ? model : { ...model, headers };
|
||||
}
|
||||
|
||||
function wrapPluginProviderStream(streamFn: StreamFn): StreamFn {
|
||||
return (model, context, options) => {
|
||||
const host = getAiTransportHost();
|
||||
const apiKey = options?.apiKey ? host.resolveSecretSentinel(options.apiKey) : options?.apiKey;
|
||||
const headers = resolveAiTransportHeaderSentinels(options?.headers);
|
||||
return streamFn(
|
||||
resolveModelHeaderSentinels(model),
|
||||
context,
|
||||
apiKey === options?.apiKey && headers === options?.headers
|
||||
? options
|
||||
: { ...options, apiKey, headers },
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function registerProviderStreamForModel<TApi extends Api>(params: {
|
||||
model: Model<TApi>;
|
||||
cfg?: unknown;
|
||||
apiRegistry: ApiRegistry;
|
||||
}): StreamFn | undefined {
|
||||
const pluginModel = resolveModelHeaderSentinels(params.model);
|
||||
const providerStreamFn = getAiTransportHost().plugin.resolveProviderStream({
|
||||
provider: params.model.provider,
|
||||
config: params.cfg,
|
||||
context: {
|
||||
config: params.cfg,
|
||||
provider: params.model.provider,
|
||||
modelId: params.model.id,
|
||||
model: pluginModel,
|
||||
},
|
||||
});
|
||||
const transportFallback = providerStreamFn
|
||||
? undefined
|
||||
: createTransportAwareStreamFnForModel(
|
||||
params.model.api === "google-generative-ai" ? pluginModel : params.model,
|
||||
{ cfg: params.cfg },
|
||||
);
|
||||
const streamFn = providerStreamFn
|
||||
? wrapPluginProviderStream(providerStreamFn)
|
||||
: transportFallback && params.model.api === "google-generative-ai"
|
||||
? wrapPluginProviderStream(transportFallback)
|
||||
: transportFallback;
|
||||
if (streamFn) {
|
||||
getAiTransportHost().registerCustomApi(params.apiRegistry, params.model.api, streamFn);
|
||||
}
|
||||
return streamFn;
|
||||
}
|
||||
|
||||
export function prepareModelForSimpleCompletion<TApi extends Api>(params: {
|
||||
apiRegistry: ApiRegistry;
|
||||
model: Model<TApi>;
|
||||
cfg?: unknown;
|
||||
}): Model {
|
||||
const { apiRegistry, model, cfg } = params;
|
||||
// Only provider-owned custom APIs need runtime stream registration here.
|
||||
if (
|
||||
!apiRegistry.getApiProvider(model.api) &&
|
||||
registerProviderStreamForModel({ model, cfg, apiRegistry })
|
||||
) {
|
||||
return applyProviderSimpleCompletionWrapper(apiRegistry, model, cfg);
|
||||
}
|
||||
|
||||
const codexTransportModel = prepareCodexSimpleTransportModel(apiRegistry, model, cfg);
|
||||
if (codexTransportModel) {
|
||||
return applyProviderSimpleCompletionWrapper(apiRegistry, codexTransportModel, cfg);
|
||||
}
|
||||
|
||||
const transportAwareModel = prepareTransportAwareSimpleModel(model, { cfg });
|
||||
if (transportAwareModel !== model) {
|
||||
const streamFn = buildTransportAwareSimpleStreamFn(model, { cfg });
|
||||
if (streamFn) {
|
||||
getAiTransportHost().registerCustomApi(apiRegistry, transportAwareModel.api, streamFn);
|
||||
return applyProviderSimpleCompletionWrapper(apiRegistry, transportAwareModel, cfg);
|
||||
}
|
||||
}
|
||||
|
||||
if (model.api === "google-generative-ai") {
|
||||
return applyProviderSimpleCompletionWrapper(
|
||||
apiRegistry,
|
||||
getAiTransportHost().prepareGoogleSimpleCompletionModel(apiRegistry, model),
|
||||
cfg,
|
||||
);
|
||||
}
|
||||
|
||||
if (model.provider === "anthropic-vertex") {
|
||||
const api = resolveAnthropicVertexSimpleApi(model.baseUrl);
|
||||
getAiTransportHost().registerCustomApi(
|
||||
apiRegistry,
|
||||
api,
|
||||
getAiTransportHost().plugin.createAnthropicVertexStream(model),
|
||||
);
|
||||
return applyProviderSimpleCompletionWrapper(apiRegistry, { ...model, api }, cfg);
|
||||
}
|
||||
|
||||
return applyProviderSimpleCompletionWrapper(apiRegistry, model, cfg);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Shared transport-stream normalization helpers.
|
||||
*
|
||||
* Sanitizes provider payloads, merges metadata, and formats streamed assistant events.
|
||||
*/
|
||||
import type { Usage } from "@openclaw/llm-core";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { sanitizeSurrogates } from "../internal/shared.js";
|
||||
import { createAssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { redactSensitiveText } from "./transport-utils.js";
|
||||
|
||||
type ContextUsage = NonNullable<Usage["contextUsage"]>;
|
||||
|
||||
type TransportUsage = {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
contextUsage?: ContextUsage;
|
||||
totalTokens: number;
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
|
||||
};
|
||||
|
||||
export type WritableTransportStream = {
|
||||
push(event: unknown): void;
|
||||
end(): void;
|
||||
};
|
||||
|
||||
type TransportOutputShape = {
|
||||
stopReason: string;
|
||||
errorMessage?: string;
|
||||
errorCode?: string;
|
||||
errorType?: string;
|
||||
errorBody?: string;
|
||||
};
|
||||
|
||||
const EMPTY_TOOL_RESULT_TEXT = "(no output)";
|
||||
/**
|
||||
* Encodes an assistant text-block phase signature (v1). Channels and the
|
||||
* embedded handler read this to route commentary/narration out of the final
|
||||
* reply. Shared so every provider transport tags phases identically.
|
||||
*/
|
||||
export function encodeAssistantTextSignatureV1(
|
||||
id: string,
|
||||
phase?: "commentary" | "final_answer",
|
||||
): string {
|
||||
return JSON.stringify({ v: 1, id, ...(phase ? { phase } : {}) });
|
||||
}
|
||||
|
||||
export function sanitizeTransportPayloadText(text: string): string {
|
||||
if (typeof text !== "string") {
|
||||
return "";
|
||||
}
|
||||
return sanitizeSurrogates(text);
|
||||
}
|
||||
|
||||
export function sanitizeNonEmptyTransportPayloadText(
|
||||
text: string,
|
||||
fallback = EMPTY_TOOL_RESULT_TEXT,
|
||||
): string {
|
||||
const sanitized = sanitizeTransportPayloadText(text);
|
||||
return sanitized.trim().length > 0 ? sanitized : fallback;
|
||||
}
|
||||
|
||||
export function coerceTransportToolCallArguments(argumentsValue: unknown): Record<string, unknown> {
|
||||
if (argumentsValue && typeof argumentsValue === "object" && !Array.isArray(argumentsValue)) {
|
||||
return argumentsValue as Record<string, unknown>;
|
||||
}
|
||||
if (typeof argumentsValue === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(argumentsValue);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Preserve malformed strings in stored history, but send object-shaped payloads to
|
||||
// providers that require structured tool-call arguments.
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function mergeTransportHeaders(
|
||||
...headerSources: Array<Record<string, string> | undefined>
|
||||
): Record<string, string> | undefined {
|
||||
const merged: Record<string, string> = {};
|
||||
for (const headers of headerSources) {
|
||||
if (headers) {
|
||||
Object.assign(merged, headers);
|
||||
}
|
||||
}
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
export function mergeTransportMetadata<T extends Record<string, unknown>>(
|
||||
payload: T,
|
||||
metadata?: Record<string, string>,
|
||||
): T {
|
||||
if (!metadata || Object.keys(metadata).length === 0) {
|
||||
return payload;
|
||||
}
|
||||
const existingMetadata =
|
||||
payload.metadata && typeof payload.metadata === "object" && !Array.isArray(payload.metadata)
|
||||
? (payload.metadata as Record<string, string>)
|
||||
: undefined;
|
||||
return {
|
||||
...payload,
|
||||
metadata: {
|
||||
...existingMetadata,
|
||||
...metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyTransportUsage(): TransportUsage {
|
||||
return {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function createWritableTransportEventStream() {
|
||||
const eventStream = createAssistantMessageEventStream();
|
||||
return {
|
||||
eventStream,
|
||||
stream: eventStream as unknown as WritableTransportStream,
|
||||
};
|
||||
}
|
||||
|
||||
export function finalizeTransportStream(params: {
|
||||
stream: WritableTransportStream;
|
||||
output: TransportOutputShape;
|
||||
signal?: AbortSignal;
|
||||
}): void {
|
||||
const { stream, output, signal } = params;
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
||||
throw new Error(output.errorMessage ?? "An unknown error occurred");
|
||||
}
|
||||
stream.push({ type: "done", reason: output.stopReason as never, message: output as never });
|
||||
stream.end();
|
||||
}
|
||||
|
||||
type TransportErrorDetails = {
|
||||
errorCode?: string;
|
||||
errorType?: string;
|
||||
errorBody?: string;
|
||||
};
|
||||
|
||||
function readStringLikeProperty(value: unknown, key: string): string | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const raw = (value as Record<string, unknown>)[key];
|
||||
if (typeof raw === "string") {
|
||||
const trimmed = raw.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return String(raw);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readObjectProperty(value: unknown, key: string): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const raw = (value as Record<string, unknown>)[key];
|
||||
return raw && typeof raw === "object" && !Array.isArray(raw)
|
||||
? (raw as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function stringifyErrorBody(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyTransportErrorMessage(value: unknown): string | undefined {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
const encoded = stringifyErrorBody(value);
|
||||
if (encoded !== undefined) {
|
||||
return encoded;
|
||||
}
|
||||
try {
|
||||
return String(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTransportErrorBody(value: unknown): string | undefined {
|
||||
const text = stringifyErrorBody(value);
|
||||
if (!text?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const redacted = redactSensitiveText(text);
|
||||
return redacted.length > 500 ? `${truncateUtf16Safe(redacted, 499)}…` : redacted;
|
||||
}
|
||||
|
||||
function extractTransportErrorDetails(error: unknown): TransportErrorDetails {
|
||||
const errorObject = error && typeof error === "object" ? error : undefined;
|
||||
const nestedError = readObjectProperty(errorObject, "error");
|
||||
const errorCode =
|
||||
readStringLikeProperty(errorObject, "errorCode") ??
|
||||
readStringLikeProperty(errorObject, "code") ??
|
||||
readStringLikeProperty(nestedError, "code");
|
||||
const errorType =
|
||||
readStringLikeProperty(errorObject, "errorType") ??
|
||||
readStringLikeProperty(errorObject, "type") ??
|
||||
readStringLikeProperty(nestedError, "type");
|
||||
const errorBody =
|
||||
normalizeTransportErrorBody(readStringLikeProperty(errorObject, "errorBody")) ??
|
||||
normalizeTransportErrorBody(readStringLikeProperty(errorObject, "body")) ??
|
||||
normalizeTransportErrorBody(readObjectProperty(errorObject, "body")) ??
|
||||
normalizeTransportErrorBody(nestedError);
|
||||
|
||||
return {
|
||||
...(errorCode ? { errorCode } : {}),
|
||||
...(errorType ? { errorType } : {}),
|
||||
...(errorBody ? { errorBody } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function assignTransportErrorDetails(
|
||||
output: TransportOutputShape,
|
||||
error: unknown,
|
||||
signal?: AbortSignal,
|
||||
): void {
|
||||
output.stopReason = signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = stringifyTransportErrorMessage(error);
|
||||
Object.assign(output, extractTransportErrorDetails(error));
|
||||
}
|
||||
|
||||
export function failTransportStream(params: {
|
||||
stream: WritableTransportStream;
|
||||
output: TransportOutputShape;
|
||||
signal?: AbortSignal;
|
||||
error: unknown;
|
||||
cleanup?: () => void;
|
||||
}): void {
|
||||
const { stream, output, signal, error, cleanup } = params;
|
||||
cleanup?.();
|
||||
assignTransportErrorDetails(output, error, signal);
|
||||
stream.push({ type: "error", reason: output.stopReason as never, error: output as never });
|
||||
stream.end();
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Model } from "@openclaw/llm-core";
|
||||
import {
|
||||
asFiniteNumberInRange,
|
||||
parseStrictFiniteNumber,
|
||||
parseStrictNonNegativeInteger,
|
||||
} from "@openclaw/normalization-core/number-coercion";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { getAiTransportHost } from "../host.js";
|
||||
import { parseRetryAfterHttpDateMs } from "../internal/retry-after.js";
|
||||
|
||||
export const MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE =
|
||||
"OpenClaw transport error: malformed_streaming_fragment";
|
||||
export const CHARS_PER_TOKEN_ESTIMATE = 4;
|
||||
const NON_LATIN_RE =
|
||||
/[\u2E80-\u9FFF\uA000-\uA4FF\uAC00-\uD7AF\uF900-\uFAFF\uFF01-\uFF60\uFFE0-\uFFE6\u{20000}-\u{2FA1F}]/gu;
|
||||
const CJK_SURROGATE_HIGH_RE = /[\uD840-\uD87E][\uDC00-\uDFFF]/g;
|
||||
|
||||
export function sha256Hex(value: string | Uint8Array): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function sha256HexPrefix(value: string | Uint8Array, length: number): string {
|
||||
return sha256Hex(value).slice(0, length);
|
||||
}
|
||||
|
||||
export function redactIdentifier(value: string | undefined, opts?: { len?: number }): string {
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
if (!trimmed) {
|
||||
return "-";
|
||||
}
|
||||
const length = Number.isFinite(opts?.len) ? Math.max(1, Math.floor(opts?.len ?? 12)) : 12;
|
||||
return `sha256:${sha256HexPrefix(trimmed, length)}`;
|
||||
}
|
||||
|
||||
export function redactSensitiveText(text: string, _options?: unknown): string {
|
||||
return getAiTransportHost().redactToolPayloadText(text);
|
||||
}
|
||||
|
||||
export function resolveSecretSentinel(value: string): string {
|
||||
return getAiTransportHost().resolveSecretSentinel(value);
|
||||
}
|
||||
|
||||
export function resolveModelHeaderSentinels<TModel extends Model>(model: TModel): TModel {
|
||||
if (!model.headers) {
|
||||
return model;
|
||||
}
|
||||
let headers: Record<string, string> | undefined;
|
||||
for (const [name, value] of Object.entries(model.headers)) {
|
||||
const resolved = resolveSecretSentinel(value);
|
||||
if (resolved !== value) {
|
||||
headers ??= { ...model.headers };
|
||||
headers[name] = resolved;
|
||||
}
|
||||
}
|
||||
return headers ? ({ ...model, headers } as TModel) : model;
|
||||
}
|
||||
|
||||
export function createAbortError(message: string, options?: ErrorOptions): Error {
|
||||
const error = new Error(message, options);
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
export function estimateStringChars(text: string): number {
|
||||
if (!text) {
|
||||
return 0;
|
||||
}
|
||||
const nonLatinCount = (text.match(NON_LATIN_RE) ?? []).length;
|
||||
const codePointLength =
|
||||
nonLatinCount === 0
|
||||
? text.length
|
||||
: text.length - (text.match(CJK_SURROGATE_HIGH_RE) ?? []).length;
|
||||
return codePointLength + nonLatinCount * (CHARS_PER_TOKEN_ESTIMATE - 1);
|
||||
}
|
||||
|
||||
export function supportsModelTools(model: { compat?: unknown }): boolean {
|
||||
const compat =
|
||||
model.compat && typeof model.compat === "object"
|
||||
? (model.compat as { supportsTools?: boolean })
|
||||
: undefined;
|
||||
return compat?.supportsTools !== false;
|
||||
}
|
||||
|
||||
export function isCodeModeModelVisibleToolName(name: string): boolean {
|
||||
return name === "exec" || name === "wait" || name === "computer" || name === "image";
|
||||
}
|
||||
|
||||
function isGoogleGemini3Model(modelId: string, family: "flash" | "pro"): boolean {
|
||||
const normalized = modelId.trim().toLowerCase();
|
||||
const suffix = family === "pro" ? "pro" : "flash";
|
||||
return new RegExp(
|
||||
`(?:^|/)gemini-(?:3(?:\\.\\d+)?-${suffix}|${suffix}${family === "flash" ? "(?:-lite)?" : ""}-latest)(?:-|$)`,
|
||||
).test(normalized);
|
||||
}
|
||||
|
||||
export function isGoogleGemini3ProModel(modelId: string): boolean {
|
||||
return isGoogleGemini3Model(modelId, "pro");
|
||||
}
|
||||
|
||||
export function isGoogleGemini3FlashModel(modelId: string): boolean {
|
||||
return isGoogleGemini3Model(modelId, "flash");
|
||||
}
|
||||
|
||||
export function parseRetryAfterSeconds(headers: Headers): number | undefined {
|
||||
const retryAfterMs = headers.get("retry-after-ms");
|
||||
if (retryAfterMs) {
|
||||
const trimmed = retryAfterMs.trim();
|
||||
if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
|
||||
const milliseconds = asFiniteNumberInRange(parseStrictFiniteNumber(trimmed), {
|
||||
min: 0,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
return milliseconds === undefined ? Number.POSITIVE_INFINITY : milliseconds / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
const retryAfter = headers.get("retry-after")?.trim();
|
||||
if (!retryAfter) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^\d+$/.test(retryAfter)) {
|
||||
return parseStrictNonNegativeInteger(retryAfter) ?? Number.POSITIVE_INFINITY;
|
||||
}
|
||||
const retryAt = parseRetryAfterHttpDateMs(retryAfter);
|
||||
return retryAt === undefined ? undefined : Math.max(0, (retryAt - Date.now()) / 1000);
|
||||
}
|
||||
|
||||
async function readChunkWithIdleTimeout(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
timeoutMs: number,
|
||||
onIdleTimeout?: (params: { chunkTimeoutMs: number }) => Error,
|
||||
): Promise<ReadableStreamReadResult<Uint8Array>> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
reader.read(),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
reject(onIdleTimeout?.({ chunkTimeoutMs: timeoutMs }) ?? new Error("Read timed out")),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readResponseTextSnippet(
|
||||
response: Response,
|
||||
options?: {
|
||||
maxBytes?: number;
|
||||
maxChars?: number;
|
||||
chunkTimeoutMs?: number;
|
||||
onIdleTimeout?: (params: { chunkTimeoutMs: number }) => Error;
|
||||
},
|
||||
): Promise<string | undefined> {
|
||||
const maxBytes = options?.maxBytes ?? 8 * 1024;
|
||||
const maxChars = options?.maxChars ?? 200;
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
return undefined;
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
let truncated = false;
|
||||
try {
|
||||
while (bytes < maxBytes) {
|
||||
const result = options?.chunkTimeoutMs
|
||||
? await readChunkWithIdleTimeout(reader, options.chunkTimeoutMs, options.onIdleTimeout)
|
||||
: await reader.read();
|
||||
if (result.done) {
|
||||
break;
|
||||
}
|
||||
if (!result.value?.length) {
|
||||
continue;
|
||||
}
|
||||
const remaining = maxBytes - bytes;
|
||||
chunks.push(result.value.subarray(0, remaining));
|
||||
bytes += Math.min(result.value.length, remaining);
|
||||
if (result.value.length >= remaining) {
|
||||
truncated = true;
|
||||
await reader.cancel().catch(() => undefined);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await reader.cancel(error).catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const merged = new Uint8Array(bytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
const collapsed = new TextDecoder().decode(merged).replace(/\s+/g, " ").trim();
|
||||
if (!collapsed) {
|
||||
return undefined;
|
||||
}
|
||||
if (collapsed.length > maxChars) {
|
||||
return `${truncateUtf16Safe(collapsed, maxChars)}…`;
|
||||
}
|
||||
return truncated ? `${collapsed}…` : collapsed;
|
||||
}
|
||||
@@ -10,14 +10,10 @@ const apiRegistry = {
|
||||
getApiProvider: vi.fn(() => ({ streamSimple })),
|
||||
} as unknown as ApiRegistry;
|
||||
|
||||
vi.mock("../llm/stream.js", () => ({
|
||||
streamSimple,
|
||||
}));
|
||||
|
||||
vi.mock("../plugin-sdk/provider-stream-shared.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../plugin-sdk/provider-stream-shared.js")>(
|
||||
"../plugin-sdk/provider-stream-shared.js",
|
||||
);
|
||||
vi.mock("../llm/providers/stream-wrappers/google-thinking-payload.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../llm/providers/stream-wrappers/google-thinking-payload.js")
|
||||
>("../llm/providers/stream-wrappers/google-thinking-payload.js");
|
||||
return {
|
||||
...actual,
|
||||
sanitizeGoogleThinkingPayload,
|
||||
@@ -164,8 +160,8 @@ describe("prepareGoogleSimpleCompletionModel", () => {
|
||||
"preserves clamped-off intent in the final Gemini 3 payload for reasoning=%s",
|
||||
async (reasoning) => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../plugin-sdk/provider-stream-shared.js")
|
||||
>("../plugin-sdk/provider-stream-shared.js");
|
||||
typeof import("../llm/providers/stream-wrappers/google-thinking-payload.js")
|
||||
>("../llm/providers/stream-wrappers/google-thinking-payload.js");
|
||||
sanitizeGoogleThinkingPayload.mockImplementationOnce(actual.sanitizeGoogleThinkingPayload);
|
||||
streamSimple.mockImplementationOnce((_model, _context, options) => {
|
||||
const payload = {
|
||||
|
||||
@@ -6,12 +6,12 @@ import type { ApiRegistry } from "@openclaw/ai";
|
||||
* backend but sanitizes unsupported thinking payload options for simple models.
|
||||
*/
|
||||
import { clampThinkingLevel } from "@openclaw/ai/internal/runtime";
|
||||
import type { Api, Model, ModelThinkingLevel } from "../llm/types.js";
|
||||
import {
|
||||
sanitizeGoogleThinkingPayload,
|
||||
streamWithPayloadPatch,
|
||||
type GoogleThinkingInputLevel,
|
||||
} from "../plugin-sdk/provider-stream-shared.js";
|
||||
} from "../llm/providers/stream-wrappers/google-thinking-payload.js";
|
||||
import { streamWithPayloadPatch } from "../llm/providers/stream-wrappers/stream-payload-utils.js";
|
||||
import type { Api, Model, ModelThinkingLevel } from "../llm/types.js";
|
||||
import { ensureCustomApiRegistered } from "./custom-api-registry.js";
|
||||
import type { StreamFn } from "./runtime/index.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export type GoogleThinkingLevel = "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export type GoogleThinkingInputLevel =
|
||||
| "off"
|
||||
| "minimal"
|
||||
| "low"
|
||||
| "medium"
|
||||
| "adaptive"
|
||||
| "high"
|
||||
| "max"
|
||||
| "xhigh";
|
||||
|
||||
// Gemini 2.5 Pro only works in thinking mode and rejects thinkingBudget=0 with
|
||||
// "Budget 0 is invalid. This model only works in thinking mode."
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export function isGoogleThinkingRequiredModel(modelId: string): boolean {
|
||||
return normalizeLowercaseStringOrEmpty(modelId).includes("gemini-2.5-pro");
|
||||
}
|
||||
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export function isGoogleGemini25ThinkingBudgetModel(modelId: string): boolean {
|
||||
return /(?:^|\/)gemini-2\.5-/.test(normalizeLowercaseStringOrEmpty(modelId));
|
||||
}
|
||||
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export function isGoogleGemini3ProModel(modelId: string): boolean {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return /(?:^|\/)gemini-(?:3(?:\.\d+)?-pro|pro-latest)(?:-|$)/.test(normalized);
|
||||
}
|
||||
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export function isGoogleGemini3FlashModel(modelId: string): boolean {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return /(?:^|\/)gemini-(?:3(?:\.\d+)?-flash|flash(?:-lite)?-latest)(?:-|$)/.test(normalized);
|
||||
}
|
||||
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export function isGoogleGemini3ThinkingLevelModel(modelId: string): boolean {
|
||||
return isGoogleGemini3ProModel(modelId) || isGoogleGemini3FlashModel(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps legacy numeric/semantic thinking input onto Gemini 3's provider enum.
|
||||
* @deprecated Google provider-owned stream helper; do not use from third-party plugins.
|
||||
*/
|
||||
export function resolveGoogleGemini3ThinkingLevel(params: {
|
||||
modelId?: string;
|
||||
thinkingLevel?: GoogleThinkingInputLevel;
|
||||
thinkingBudget?: number;
|
||||
}): GoogleThinkingLevel | undefined {
|
||||
if (typeof params.modelId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
if (isGoogleGemini3ProModel(params.modelId)) {
|
||||
switch (params.thinkingLevel) {
|
||||
case "off":
|
||||
case "minimal":
|
||||
case "low":
|
||||
return "LOW";
|
||||
case "medium":
|
||||
case "high":
|
||||
case "max":
|
||||
case "xhigh":
|
||||
return "HIGH";
|
||||
case "adaptive":
|
||||
return undefined;
|
||||
case undefined:
|
||||
break;
|
||||
}
|
||||
if (typeof params.thinkingBudget === "number") {
|
||||
if (params.thinkingBudget < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return params.thinkingBudget <= 2048 ? "LOW" : "HIGH";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!isGoogleGemini3FlashModel(params.modelId)) {
|
||||
return undefined;
|
||||
}
|
||||
switch (params.thinkingLevel) {
|
||||
case "off":
|
||||
case "minimal":
|
||||
return "MINIMAL";
|
||||
case "low":
|
||||
return "LOW";
|
||||
case "medium":
|
||||
return "MEDIUM";
|
||||
case "high":
|
||||
case "max":
|
||||
case "xhigh":
|
||||
return "HIGH";
|
||||
case "adaptive":
|
||||
return undefined;
|
||||
case undefined:
|
||||
break;
|
||||
}
|
||||
if (typeof params.thinkingBudget !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
if (params.thinkingBudget < 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (params.thinkingBudget <= 0) {
|
||||
return "MINIMAL";
|
||||
}
|
||||
if (params.thinkingBudget <= 2048) {
|
||||
return "LOW";
|
||||
}
|
||||
if (params.thinkingBudget <= 8192) {
|
||||
return "MEDIUM";
|
||||
}
|
||||
return "HIGH";
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes `thinkingBudget=0` only for Gemini models that reject disabled thinking.
|
||||
* @deprecated Google provider-owned stream helper; do not use from third-party plugins.
|
||||
*/
|
||||
export function stripInvalidGoogleThinkingBudget(params: {
|
||||
thinkingConfig: Record<string, unknown>;
|
||||
modelId?: string;
|
||||
}): boolean {
|
||||
if (
|
||||
params.thinkingConfig.thinkingBudget !== 0 ||
|
||||
typeof params.modelId !== "string" ||
|
||||
!isGoogleThinkingRequiredModel(params.modelId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
delete params.thinkingConfig.thinkingBudget;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isGemma4Model(modelId: string): boolean {
|
||||
return normalizeLowercaseStringOrEmpty(modelId).startsWith("gemma-4");
|
||||
}
|
||||
|
||||
function mapThinkLevelToGemma4ThinkingLevel(
|
||||
thinkingLevel?: GoogleThinkingInputLevel,
|
||||
): "MINIMAL" | "HIGH" | undefined {
|
||||
switch (thinkingLevel) {
|
||||
case "off":
|
||||
return undefined;
|
||||
case "minimal":
|
||||
case "low":
|
||||
return "MINIMAL";
|
||||
case "medium":
|
||||
case "adaptive":
|
||||
case "high":
|
||||
case "max":
|
||||
case "xhigh":
|
||||
return "HIGH";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGemma4ThinkingLevel(value: unknown): "MINIMAL" | "HIGH" | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
switch (value.trim().toUpperCase()) {
|
||||
case "MINIMAL":
|
||||
case "LOW":
|
||||
return "MINIMAL";
|
||||
case "MEDIUM":
|
||||
case "HIGH":
|
||||
return "HIGH";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes Google thinking config across SDK payload shapes before provider transport.
|
||||
* @deprecated Google provider-owned stream helper; do not use from third-party plugins.
|
||||
*/
|
||||
export function sanitizeGoogleThinkingPayload(params: {
|
||||
payload: unknown;
|
||||
modelId?: string;
|
||||
thinkingLevel?: GoogleThinkingInputLevel;
|
||||
}): void {
|
||||
if (!params.payload || typeof params.payload !== "object") {
|
||||
return;
|
||||
}
|
||||
const payloadObj = params.payload as Record<string, unknown>;
|
||||
sanitizeGoogleThinkingConfigContainer({
|
||||
container: payloadObj.config,
|
||||
modelId: params.modelId,
|
||||
thinkingLevel: params.thinkingLevel,
|
||||
});
|
||||
sanitizeGoogleThinkingConfigContainer({
|
||||
container: payloadObj.generationConfig,
|
||||
modelId: params.modelId,
|
||||
thinkingLevel: params.thinkingLevel,
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeGoogleThinkingConfigContainer(params: {
|
||||
container: unknown;
|
||||
modelId?: string;
|
||||
thinkingLevel?: GoogleThinkingInputLevel;
|
||||
}): void {
|
||||
if (!params.container || typeof params.container !== "object") {
|
||||
return;
|
||||
}
|
||||
const configObj = params.container as Record<string, unknown>;
|
||||
const thinkingConfig = configObj.thinkingConfig;
|
||||
if (!thinkingConfig || typeof thinkingConfig !== "object") {
|
||||
return;
|
||||
}
|
||||
const thinkingConfigObj = thinkingConfig as Record<string, unknown>;
|
||||
|
||||
if (typeof params.modelId === "string" && isGemma4Model(params.modelId)) {
|
||||
// Gemma 4 accepts thinkingLevel but not thinkingBudget; map legacy budget
|
||||
// inputs before deleting the unsupported numeric field.
|
||||
const normalizedThinkingLevel = normalizeGemma4ThinkingLevel(thinkingConfigObj.thinkingLevel);
|
||||
const explicitMappedLevel = mapThinkLevelToGemma4ThinkingLevel(params.thinkingLevel);
|
||||
const disabledViaBudget =
|
||||
typeof thinkingConfigObj.thinkingBudget === "number" && thinkingConfigObj.thinkingBudget <= 0;
|
||||
const hadThinkingBudget = thinkingConfigObj.thinkingBudget !== undefined;
|
||||
delete thinkingConfigObj.thinkingBudget;
|
||||
|
||||
if (
|
||||
params.thinkingLevel === "off" ||
|
||||
(disabledViaBudget && explicitMappedLevel === undefined && !normalizedThinkingLevel)
|
||||
) {
|
||||
delete thinkingConfigObj.thinkingLevel;
|
||||
if (Object.keys(thinkingConfigObj).length === 0) {
|
||||
delete configObj.thinkingConfig;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedLevel =
|
||||
explicitMappedLevel ?? normalizedThinkingLevel ?? (hadThinkingBudget ? "MINIMAL" : undefined);
|
||||
|
||||
if (mappedLevel) {
|
||||
thinkingConfigObj.thinkingLevel = mappedLevel;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const thinkingBudget = thinkingConfigObj.thinkingBudget;
|
||||
|
||||
if (
|
||||
params.thinkingLevel === "adaptive" &&
|
||||
typeof params.modelId === "string" &&
|
||||
isGoogleGemini25ThinkingBudgetModel(params.modelId)
|
||||
) {
|
||||
delete thinkingConfigObj.thinkingLevel;
|
||||
thinkingConfigObj.thinkingBudget = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
params.thinkingLevel === "adaptive" &&
|
||||
typeof params.modelId === "string" &&
|
||||
isGoogleGemini3ThinkingLevelModel(params.modelId)
|
||||
) {
|
||||
// Gemini 3 adaptive mode means omit both controls so the provider chooses.
|
||||
delete thinkingConfigObj.thinkingBudget;
|
||||
delete thinkingConfigObj.thinkingLevel;
|
||||
if (Object.keys(thinkingConfigObj).length === 0) {
|
||||
delete configObj.thinkingConfig;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof params.modelId === "string" && isGoogleGemini3ThinkingLevelModel(params.modelId)) {
|
||||
const mappedLevel = resolveGoogleGemini3ThinkingLevel({
|
||||
modelId: params.modelId,
|
||||
thinkingLevel: params.thinkingLevel,
|
||||
thinkingBudget: typeof thinkingBudget === "number" ? thinkingBudget : undefined,
|
||||
});
|
||||
delete thinkingConfigObj.thinkingBudget;
|
||||
if (mappedLevel) {
|
||||
// Gemini 3 uses thinkingLevel; leaving thinkingBudget would make mixed-mode payloads.
|
||||
thinkingConfigObj.thinkingLevel = mappedLevel;
|
||||
}
|
||||
if (Object.keys(thinkingConfigObj).length === 0) {
|
||||
delete configObj.thinkingConfig;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
stripInvalidGoogleThinkingBudget({ thinkingConfig: thinkingConfigObj, modelId: params.modelId })
|
||||
) {
|
||||
if (Object.keys(thinkingConfigObj).length === 0) {
|
||||
delete configObj.thinkingConfig;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof thinkingBudget !== "number" || thinkingBudget >= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// shared model runtime can emit thinkingBudget=-1 for some Google model IDs; a negative budget
|
||||
// is invalid for Google-compatible backends and can lead to malformed handling.
|
||||
delete thinkingConfigObj.thinkingBudget;
|
||||
if (Object.keys(thinkingConfigObj).length === 0) {
|
||||
delete configObj.thinkingConfig;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Public facade for Google thinking payload wrappers shared with plugin providers.
|
||||
export {
|
||||
createGoogleThinkingPayloadWrapper,
|
||||
sanitizeGoogleThinkingPayload,
|
||||
} from "../../../plugin-sdk/provider-stream-shared.js";
|
||||
export { createGoogleThinkingPayloadWrapper } from "../../../plugin-sdk/provider-stream-shared.js";
|
||||
export { sanitizeGoogleThinkingPayload } from "./google-thinking-payload.js";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Provider stream shared helpers implement reusable stream wrappers and payload policies.
|
||||
import { resolveOpenAIReasoningEffortForModel } from "@openclaw/ai/internal/openai";
|
||||
import { resolveOpenAIReasoningEffortMap } from "@openclaw/ai/transports";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js";
|
||||
import {
|
||||
createPromotedPlainTextToolCallBlock,
|
||||
createPromotedPlainTextToolCallEvents,
|
||||
@@ -14,6 +13,10 @@ import {
|
||||
} from "../../packages/tool-call-repair/src/index.js";
|
||||
import type { StreamFn } from "../agents/runtime/index.js";
|
||||
import type { ThinkLevel } from "../auto-reply/thinking.js";
|
||||
import {
|
||||
sanitizeGoogleThinkingPayload,
|
||||
type GoogleThinkingInputLevel,
|
||||
} from "../llm/providers/stream-wrappers/google-thinking-payload.js";
|
||||
import { mapThinkingLevelToReasoningEffort } from "../llm/providers/stream-wrappers/reasoning-effort-utils.js";
|
||||
import { streamWithPayloadPatch } from "../llm/providers/stream-wrappers/stream-payload-utils.js";
|
||||
import { streamSimple } from "../llm/stream.js";
|
||||
@@ -647,314 +650,18 @@ export function createThinkingOnlyFinalTextWrapper(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export type GoogleThinkingLevel = "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export type GoogleThinkingInputLevel =
|
||||
| "off"
|
||||
| "minimal"
|
||||
| "low"
|
||||
| "medium"
|
||||
| "adaptive"
|
||||
| "high"
|
||||
| "max"
|
||||
| "xhigh";
|
||||
|
||||
// Gemini 2.5 Pro only works in thinking mode and rejects thinkingBudget=0 with
|
||||
// "Budget 0 is invalid. This model only works in thinking mode."
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export function isGoogleThinkingRequiredModel(modelId: string): boolean {
|
||||
return normalizeLowercaseStringOrEmpty(modelId).includes("gemini-2.5-pro");
|
||||
}
|
||||
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export function isGoogleGemini25ThinkingBudgetModel(modelId: string): boolean {
|
||||
return /(?:^|\/)gemini-2\.5-/.test(normalizeLowercaseStringOrEmpty(modelId));
|
||||
}
|
||||
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export function isGoogleGemini3ProModel(modelId: string): boolean {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return /(?:^|\/)gemini-(?:3(?:\.\d+)?-pro|pro-latest)(?:-|$)/.test(normalized);
|
||||
}
|
||||
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export function isGoogleGemini3FlashModel(modelId: string): boolean {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return /(?:^|\/)gemini-(?:3(?:\.\d+)?-flash|flash(?:-lite)?-latest)(?:-|$)/.test(normalized);
|
||||
}
|
||||
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export function isGoogleGemini3ThinkingLevelModel(modelId: string): boolean {
|
||||
return isGoogleGemini3ProModel(modelId) || isGoogleGemini3FlashModel(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps legacy numeric/semantic thinking input onto Gemini 3's provider enum.
|
||||
* @deprecated Google provider-owned stream helper; do not use from third-party plugins.
|
||||
*/
|
||||
export function resolveGoogleGemini3ThinkingLevel(params: {
|
||||
modelId?: string;
|
||||
thinkingLevel?: GoogleThinkingInputLevel;
|
||||
thinkingBudget?: number;
|
||||
}): GoogleThinkingLevel | undefined {
|
||||
if (typeof params.modelId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
if (isGoogleGemini3ProModel(params.modelId)) {
|
||||
switch (params.thinkingLevel) {
|
||||
case "off":
|
||||
case "minimal":
|
||||
case "low":
|
||||
return "LOW";
|
||||
case "medium":
|
||||
case "high":
|
||||
case "max":
|
||||
case "xhigh":
|
||||
return "HIGH";
|
||||
case "adaptive":
|
||||
return undefined;
|
||||
case undefined:
|
||||
break;
|
||||
}
|
||||
if (typeof params.thinkingBudget === "number") {
|
||||
if (params.thinkingBudget < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return params.thinkingBudget <= 2048 ? "LOW" : "HIGH";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!isGoogleGemini3FlashModel(params.modelId)) {
|
||||
return undefined;
|
||||
}
|
||||
switch (params.thinkingLevel) {
|
||||
case "off":
|
||||
case "minimal":
|
||||
return "MINIMAL";
|
||||
case "low":
|
||||
return "LOW";
|
||||
case "medium":
|
||||
return "MEDIUM";
|
||||
case "high":
|
||||
case "max":
|
||||
case "xhigh":
|
||||
return "HIGH";
|
||||
case "adaptive":
|
||||
return undefined;
|
||||
case undefined:
|
||||
break;
|
||||
}
|
||||
if (typeof params.thinkingBudget !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
if (params.thinkingBudget < 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (params.thinkingBudget <= 0) {
|
||||
return "MINIMAL";
|
||||
}
|
||||
if (params.thinkingBudget <= 2048) {
|
||||
return "LOW";
|
||||
}
|
||||
if (params.thinkingBudget <= 8192) {
|
||||
return "MEDIUM";
|
||||
}
|
||||
return "HIGH";
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes `thinkingBudget=0` only for Gemini models that reject disabled thinking.
|
||||
* @deprecated Google provider-owned stream helper; do not use from third-party plugins.
|
||||
*/
|
||||
export function stripInvalidGoogleThinkingBudget(params: {
|
||||
thinkingConfig: Record<string, unknown>;
|
||||
modelId?: string;
|
||||
}): boolean {
|
||||
if (
|
||||
params.thinkingConfig.thinkingBudget !== 0 ||
|
||||
typeof params.modelId !== "string" ||
|
||||
!isGoogleThinkingRequiredModel(params.modelId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
delete params.thinkingConfig.thinkingBudget;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isGemma4Model(modelId: string): boolean {
|
||||
return normalizeLowercaseStringOrEmpty(modelId).startsWith("gemma-4");
|
||||
}
|
||||
|
||||
function mapThinkLevelToGemma4ThinkingLevel(
|
||||
thinkingLevel?: GoogleThinkingInputLevel,
|
||||
): "MINIMAL" | "HIGH" | undefined {
|
||||
switch (thinkingLevel) {
|
||||
case "off":
|
||||
return undefined;
|
||||
case "minimal":
|
||||
case "low":
|
||||
return "MINIMAL";
|
||||
case "medium":
|
||||
case "adaptive":
|
||||
case "high":
|
||||
case "max":
|
||||
case "xhigh":
|
||||
return "HIGH";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGemma4ThinkingLevel(value: unknown): "MINIMAL" | "HIGH" | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
switch (value.trim().toUpperCase()) {
|
||||
case "MINIMAL":
|
||||
case "LOW":
|
||||
return "MINIMAL";
|
||||
case "MEDIUM":
|
||||
case "HIGH":
|
||||
return "HIGH";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes Google thinking config across SDK payload shapes before provider transport.
|
||||
* @deprecated Google provider-owned stream helper; do not use from third-party plugins.
|
||||
*/
|
||||
export function sanitizeGoogleThinkingPayload(params: {
|
||||
payload: unknown;
|
||||
modelId?: string;
|
||||
thinkingLevel?: GoogleThinkingInputLevel;
|
||||
}): void {
|
||||
if (!params.payload || typeof params.payload !== "object") {
|
||||
return;
|
||||
}
|
||||
const payloadObj = params.payload as Record<string, unknown>;
|
||||
sanitizeGoogleThinkingConfigContainer({
|
||||
container: payloadObj.config,
|
||||
modelId: params.modelId,
|
||||
thinkingLevel: params.thinkingLevel,
|
||||
});
|
||||
sanitizeGoogleThinkingConfigContainer({
|
||||
container: payloadObj.generationConfig,
|
||||
modelId: params.modelId,
|
||||
thinkingLevel: params.thinkingLevel,
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeGoogleThinkingConfigContainer(params: {
|
||||
container: unknown;
|
||||
modelId?: string;
|
||||
thinkingLevel?: GoogleThinkingInputLevel;
|
||||
}): void {
|
||||
if (!params.container || typeof params.container !== "object") {
|
||||
return;
|
||||
}
|
||||
const configObj = params.container as Record<string, unknown>;
|
||||
const thinkingConfig = configObj.thinkingConfig;
|
||||
if (!thinkingConfig || typeof thinkingConfig !== "object") {
|
||||
return;
|
||||
}
|
||||
const thinkingConfigObj = thinkingConfig as Record<string, unknown>;
|
||||
|
||||
if (typeof params.modelId === "string" && isGemma4Model(params.modelId)) {
|
||||
// Gemma 4 accepts thinkingLevel but not thinkingBudget; map legacy budget
|
||||
// inputs before deleting the unsupported numeric field.
|
||||
const normalizedThinkingLevel = normalizeGemma4ThinkingLevel(thinkingConfigObj.thinkingLevel);
|
||||
const explicitMappedLevel = mapThinkLevelToGemma4ThinkingLevel(params.thinkingLevel);
|
||||
const disabledViaBudget =
|
||||
typeof thinkingConfigObj.thinkingBudget === "number" && thinkingConfigObj.thinkingBudget <= 0;
|
||||
const hadThinkingBudget = thinkingConfigObj.thinkingBudget !== undefined;
|
||||
delete thinkingConfigObj.thinkingBudget;
|
||||
|
||||
if (
|
||||
params.thinkingLevel === "off" ||
|
||||
(disabledViaBudget && explicitMappedLevel === undefined && !normalizedThinkingLevel)
|
||||
) {
|
||||
delete thinkingConfigObj.thinkingLevel;
|
||||
if (Object.keys(thinkingConfigObj).length === 0) {
|
||||
delete configObj.thinkingConfig;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedLevel =
|
||||
explicitMappedLevel ?? normalizedThinkingLevel ?? (hadThinkingBudget ? "MINIMAL" : undefined);
|
||||
|
||||
if (mappedLevel) {
|
||||
thinkingConfigObj.thinkingLevel = mappedLevel;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const thinkingBudget = thinkingConfigObj.thinkingBudget;
|
||||
|
||||
if (
|
||||
params.thinkingLevel === "adaptive" &&
|
||||
typeof params.modelId === "string" &&
|
||||
isGoogleGemini25ThinkingBudgetModel(params.modelId)
|
||||
) {
|
||||
delete thinkingConfigObj.thinkingLevel;
|
||||
thinkingConfigObj.thinkingBudget = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
params.thinkingLevel === "adaptive" &&
|
||||
typeof params.modelId === "string" &&
|
||||
isGoogleGemini3ThinkingLevelModel(params.modelId)
|
||||
) {
|
||||
// Gemini 3 adaptive mode means omit both controls so the provider chooses.
|
||||
delete thinkingConfigObj.thinkingBudget;
|
||||
delete thinkingConfigObj.thinkingLevel;
|
||||
if (Object.keys(thinkingConfigObj).length === 0) {
|
||||
delete configObj.thinkingConfig;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof params.modelId === "string" && isGoogleGemini3ThinkingLevelModel(params.modelId)) {
|
||||
const mappedLevel = resolveGoogleGemini3ThinkingLevel({
|
||||
modelId: params.modelId,
|
||||
thinkingLevel: params.thinkingLevel,
|
||||
thinkingBudget: typeof thinkingBudget === "number" ? thinkingBudget : undefined,
|
||||
});
|
||||
delete thinkingConfigObj.thinkingBudget;
|
||||
if (mappedLevel) {
|
||||
// Gemini 3 uses thinkingLevel; leaving thinkingBudget would make mixed-mode payloads.
|
||||
thinkingConfigObj.thinkingLevel = mappedLevel;
|
||||
}
|
||||
if (Object.keys(thinkingConfigObj).length === 0) {
|
||||
delete configObj.thinkingConfig;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
stripInvalidGoogleThinkingBudget({ thinkingConfig: thinkingConfigObj, modelId: params.modelId })
|
||||
) {
|
||||
if (Object.keys(thinkingConfigObj).length === 0) {
|
||||
delete configObj.thinkingConfig;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof thinkingBudget !== "number" || thinkingBudget >= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// shared model runtime can emit thinkingBudget=-1 for some Google model IDs; a negative budget
|
||||
// is invalid for Google-compatible backends and can lead to malformed handling.
|
||||
delete thinkingConfigObj.thinkingBudget;
|
||||
if (Object.keys(thinkingConfigObj).length === 0) {
|
||||
delete configObj.thinkingConfig;
|
||||
}
|
||||
}
|
||||
export {
|
||||
isGoogleGemini25ThinkingBudgetModel,
|
||||
isGoogleGemini3FlashModel,
|
||||
isGoogleGemini3ProModel,
|
||||
isGoogleGemini3ThinkingLevelModel,
|
||||
isGoogleThinkingRequiredModel,
|
||||
resolveGoogleGemini3ThinkingLevel,
|
||||
sanitizeGoogleThinkingPayload,
|
||||
stripInvalidGoogleThinkingBudget,
|
||||
type GoogleThinkingInputLevel,
|
||||
type GoogleThinkingLevel,
|
||||
} from "../llm/providers/stream-wrappers/google-thinking-payload.js";
|
||||
|
||||
/** @deprecated Google provider-owned stream helper; do not use from third-party plugins. */
|
||||
export function createGoogleThinkingPayloadWrapper(
|
||||
@@ -993,4 +700,3 @@ export {
|
||||
createToolStreamWrapper,
|
||||
createZaiToolStreamWrapper,
|
||||
} from "../llm/providers/stream-wrappers/zai.js";
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
Reference in New Issue
Block a user