refactor: move provider transports into packages/ai behind a typed host port (#111669)

* refactor(ai): invert plugin coupling behind the transport host port

* fix(ai): queue custom transport registrations until the host is configured

* refactor(ai): remove relocated transport sources from src/agents

* fix(ai): source core stream types from canonical packages and fix tarball fixtures

* fix(ai): invert plugin transport host wiring

* fix(ai): harden managed transport projection

* test(ai): register synchronous stream in transport mock

* fix(ai): lazily install transport runtime host

* fix(ai): preserve completion compat detection
This commit is contained in:
Peter Steinberger
2026-07-20 23:26:00 -07:00
committed by GitHub
parent f1205f5f0b
commit b4e27f8b3d
107 changed files with 2285 additions and 903 deletions
+1
View File
@@ -372,6 +372,7 @@ const config = {
// Docker packaging stages @openclaw/ai without nested dependencies after
// verifying the root owns its exact runtime dependency versions.
"@mistralai/mistralai",
"openai",
"cross-spawn",
"file-type",
// Loaded via createRequire in src/agents/utils/syntax-highlight.ts because its
+4 -5
View File
@@ -350,6 +350,10 @@ packages/ai/src/providers/openai-completions.test.ts
packages/ai/src/providers/openai-completions.ts
packages/ai/src/providers/openai-responses-shared.test.ts
packages/ai/src/providers/openai-responses-shared.ts
packages/ai/src/transports/anthropic-transport-stream.test.ts
packages/ai/src/transports/anthropic-transport-stream.ts
packages/ai/src/transports/openai-completions-transport.ts
packages/ai/src/transports/openai-responses-transport.ts
packages/gateway-client/src/client.ts
packages/gateway-protocol/src/index.ts
packages/gateway-protocol/src/schema/agents-models-skills.ts
@@ -384,8 +388,6 @@ src/agents/agent-tools.create-openclaw-coding-tools.test.ts
src/agents/agent-tools.read.ts
src/agents/agent-tools.schema.test.ts
src/agents/agent-tools.ts
src/agents/anthropic-transport-stream.test.ts
src/agents/anthropic-transport-stream.ts
src/agents/auth-profiles/oauth-manager.ts
src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts
src/agents/auth-profiles/persisted.ts
@@ -491,8 +493,6 @@ src/agents/model-fallback.ts
src/agents/model-selection-shared.ts
src/agents/model-selection.test.ts
src/agents/models.profiles.live.test.ts
src/agents/openai-completions-transport.ts
src/agents/openai-responses-transport.ts
src/agents/openai-transport-stream.base.test.ts
src/agents/openai-transport-stream.deepseek-and-shaping.test.ts
src/agents/openai-transport-stream.inline-reasoning-and-tool-calls.test.ts
@@ -960,7 +960,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
+1 -1
View File
@@ -80,7 +80,7 @@ agents:
- Native Anthropic Messages responses expose `cache_read_input_tokens` and `cache_creation_input_tokens`, mapped to `cacheRead` and `cacheWrite`.
- `cacheRetention: "short"` maps to the default 5-minute ephemeral cache. `cacheRetention: "long"` requests the 1-hour TTL (`cache_control: { type: "ephemeral", ttl: "1h" }`) when set explicitly. An implicit/env-driven long retention (`OPENCLAW_CACHE_RETENTION=long` with no explicit `cacheRetention`) only upgrades to the 1-hour TTL on `api.anthropic.com` or Vertex AI (`aiplatform.googleapis.com` / `*-aiplatform.googleapis.com`) hosts; other hosts keep the 5-minute cache.
Source: `src/agents/anthropic-payload-policy.ts` (`resolveAnthropicEphemeralCacheControl`, `isLongTtlEligibleEndpoint`).
Source: `packages/ai/src/transports/anthropic-payload-policy.ts` (`resolveAnthropicEphemeralCacheControl`, `isLongTtlEligibleEndpoint`).
### OpenAI (direct API)
@@ -581,14 +581,29 @@
"@openclaw/ai/providers": [
"../dist/plugin-sdk/packages/ai/src/providers.d.ts"
],
"@openclaw/ai/transports": [
"../dist/plugin-sdk/packages/ai/src/transports.d.ts"
],
"@openclaw/ai/types": [
"../dist/plugin-sdk/packages/ai/src/types.d.ts"
],
"@openclaw/ai/validation": [
"../dist/plugin-sdk/packages/ai/src/validation.d.ts"
],
"@openclaw/ai/internal/*": [
"../dist/plugin-sdk/packages/ai/src/internal/*.d.ts"
"@openclaw/ai/internal/anthropic": [
"../dist/plugin-sdk/packages/ai/src/internal/anthropic.d.ts"
],
"@openclaw/ai/internal/openai": [
"../dist/plugin-sdk/packages/ai/src/internal/openai.d.ts"
],
"@openclaw/ai/internal/retry-after": [
"../dist/plugin-sdk/packages/ai/src/internal/retry-after.d.ts"
],
"@openclaw/ai/internal/runtime": [
"../dist/plugin-sdk/packages/ai/src/internal/runtime.d.ts"
],
"@openclaw/ai/internal/shared": [
"../dist/plugin-sdk/packages/ai/src/internal/shared.d.ts"
],
"@openclaw/llm-core": [
"../dist/plugin-sdk/packages/llm-core/src/index.d.ts"
+17 -2
View File
@@ -561,14 +561,29 @@
"@openclaw/ai/providers": [
"../../dist/plugin-sdk/packages/ai/src/providers.d.ts"
],
"@openclaw/ai/transports": [
"../../dist/plugin-sdk/packages/ai/src/transports.d.ts"
],
"@openclaw/ai/types": [
"../../dist/plugin-sdk/packages/ai/src/types.d.ts"
],
"@openclaw/ai/validation": [
"../../dist/plugin-sdk/packages/ai/src/validation.d.ts"
],
"@openclaw/ai/internal/*": [
"../../dist/plugin-sdk/packages/ai/src/internal/*.d.ts"
"@openclaw/ai/internal/anthropic": [
"../../dist/plugin-sdk/packages/ai/src/internal/anthropic.d.ts"
],
"@openclaw/ai/internal/openai": [
"../../dist/plugin-sdk/packages/ai/src/internal/openai.d.ts"
],
"@openclaw/ai/internal/retry-after": [
"../../dist/plugin-sdk/packages/ai/src/internal/retry-after.d.ts"
],
"@openclaw/ai/internal/runtime": [
"../../dist/plugin-sdk/packages/ai/src/internal/runtime.d.ts"
],
"@openclaw/ai/internal/shared": [
"../../dist/plugin-sdk/packages/ai/src/internal/shared.d.ts"
],
"@openclaw/llm-core": [
"../../dist/plugin-sdk/packages/llm-core/src/index.d.ts"
+6 -5
View File
@@ -14,15 +14,16 @@ registerBuiltInApiProviders(runtime.registry);
Provider-neutral contracts, validation, diagnostics, and event streams are
available from the package root and focused subpaths such as
`@openclaw/ai/event-stream` and `@openclaw/ai/validation`. No second OpenClaw
runtime package is required.
`@openclaw/ai/event-stream`, `@openclaw/ai/transports`, and
`@openclaw/ai/validation`. No second OpenClaw runtime package is required.
Provider ids, credentials, model catalogs, retries, and failover remain
application concerns. OpenClaw supplies those policies around this package.
Host policy (request fetch guarding, secret redaction, strict-tool defaults,
diagnostics logging) can be injected with `configureAiTransportHost`; the
defaults are inert.
provider plugin hooks, and diagnostics logging) can be injected with
`configureAiTransportHost`; the defaults are inert.
`@openclaw/ai/internal/*` subpaths exist for the OpenClaw application itself.
The explicit `@openclaw/ai/internal/anthropic`, `openai`, `retry-after`,
`runtime`, and `shared` subpaths exist for the OpenClaw application itself.
They carry no semver guarantee and can change or disappear in any release; do
not depend on them outside OpenClaw.
+29 -4
View File
@@ -41,6 +41,11 @@
"import": "./dist/providers.mjs",
"default": "./dist/providers.mjs"
},
"./transports": {
"types": "./dist/transports.d.mts",
"import": "./dist/transports.mjs",
"default": "./dist/transports.mjs"
},
"./diagnostics": {
"types": "./dist/diagnostics.d.mts",
"import": "./dist/diagnostics.mjs",
@@ -61,10 +66,30 @@
"import": "./dist/validation.mjs",
"default": "./dist/validation.mjs"
},
"./internal/*": {
"types": "./dist/internal/*.d.mts",
"import": "./dist/internal/*.mjs",
"default": "./dist/internal/*.mjs"
"./internal/anthropic": {
"types": "./dist/internal/anthropic.d.mts",
"import": "./dist/internal/anthropic.mjs",
"default": "./dist/internal/anthropic.mjs"
},
"./internal/openai": {
"types": "./dist/internal/openai.d.mts",
"import": "./dist/internal/openai.mjs",
"default": "./dist/internal/openai.mjs"
},
"./internal/retry-after": {
"types": "./dist/internal/retry-after.d.mts",
"import": "./dist/internal/retry-after.mjs",
"default": "./dist/internal/retry-after.mjs"
},
"./internal/runtime": {
"types": "./dist/internal/runtime.d.mts",
"import": "./dist/internal/runtime.mjs",
"default": "./dist/internal/runtime.mjs"
},
"./internal/shared": {
"types": "./dist/internal/shared.d.mts",
"import": "./dist/internal/shared.mjs",
"default": "./dist/internal/shared.mjs"
}
},
"dependencies": {
+74
View File
@@ -0,0 +1,74 @@
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-chatgpt-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).toBe(sourceModel);
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),
});
const configuredModel = prepareModelForSimpleCompletion({
apiRegistry: registry,
model: sourceModel,
});
expect(configuredModel.api).toBe(CUSTOM_API);
expect(provider?.streamSimple(configuredModel, { messages: [] })).toHaveProperty("result");
});
});
+214 -2
View File
@@ -3,7 +3,102 @@
// the embedding application (OpenClaw core installs its implementations via
// configureAiTransportHost); the library defaults below are inert so external
// consumers get safe, dependency-free behavior without wiring anything.
import type { Model } from "@openclaw/llm-core";
import type { Api, Context, Model, StreamFn } from "@openclaw/llm-core";
import type { ApiRegistry } from "./api-registry.js";
/** Provider capability facts needed by the package-owned transports. */
export interface AiProviderRequestCapabilities {
endpointClass: string;
knownProviderFamily: string;
supportsNativeStreamingUsageCompat: boolean;
supportsOpenAICompletionsStreamingUsageCompat: boolean;
usesExplicitProxyLikeEndpoint: boolean;
allowsAnthropicServiceTier: boolean;
}
/** Transport-safe provider policy input kept independent of OpenClaw config types. */
export interface AiProviderRequestPolicyInput {
provider?: string;
api?: string;
baseUrl?: string;
capability?: "llm" | "audio" | "image" | "video" | "other";
transport?: "stream" | "websocket" | "http" | "media-understanding";
modelId?: string | null;
compat?: unknown;
}
/** Context shared by plugin-owned provider stream hooks. */
export interface AiProviderStreamHookContext {
config?: unknown;
agentDir?: string;
workspaceDir?: string;
provider: string;
modelId: string;
model: Model;
}
/** Narrow plugin-runtime port used by package-owned transports. */
export interface AiTransportPluginHost {
resolveProviderStream(
this: void,
params: {
provider: string;
config?: unknown;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
allowRuntimePluginLoad?: boolean;
context: AiProviderStreamHookContext;
},
): StreamFn | undefined;
resolveTransportTurnState(
this: void,
params: {
provider: string;
modelId?: string | null;
config?: unknown;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
allowRuntimePluginLoad?: boolean;
context: {
provider: string;
modelId: string;
model?: Model;
sessionId?: string;
turnId: string;
attempt: number;
transport: "stream" | "websocket";
};
},
): { headers?: Record<string, string>; metadata?: Record<string, string> } | undefined;
wrapSimpleCompletionStream(
this: void,
params: {
provider: string;
config?: unknown;
context: AiProviderStreamHookContext & { streamFn: StreamFn };
},
): StreamFn | undefined;
createAnthropicVertexStream(
this: void,
model: Pick<Model, "baseUrl">,
env?: NodeJS.ProcessEnv,
): StreamFn;
}
/** Host-owned transcript normalization contract used immediately before provider projection. */
export type AiTransformTransportMessages = (
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"];
/** Strict-tool policy inputs for OpenAI-compatible routes. */
export interface OpenAIStrictToolSettingOptions {
@@ -36,6 +131,37 @@ export interface AiTransportHost {
model: Pick<Model, "provider" | "api" | "baseUrl" | "id"> & { compat?: unknown },
options?: OpenAIStrictToolSettingOptions,
): boolean | undefined;
/** Provider-plugin operations required by the generic package transports. */
plugin: AiTransportPluginHost;
/** Builds provider-owned Copilot compatibility headers for one message turn. */
buildCopilotDynamicHeaders(messages: Context["messages"]): Record<string, string>;
/** Resolves endpoint classification without importing core provider registries. */
resolveProviderEndpointClass(baseUrl?: string): string;
/** Resolves provider capability flags used by payload compatibility policy. */
resolveProviderRequestCapabilities(
input: AiProviderRequestPolicyInput,
): AiProviderRequestCapabilities;
/** Merges host-owned provider request headers and attribution policy. */
resolveProviderRequestHeaders(input: {
provider?: string;
api?: string;
baseUrl?: string;
providerHeaders?: Record<string, string>;
callerHeaders?: Record<string, string>;
precedence?: "caller-wins" | "defaults-win";
}): Record<string, string> | undefined;
/** Returns the host-configured request timeout attached to a model. */
resolveModelRequestTimeoutMs(model: Model): number | undefined;
/** Reports whether the model carries host-managed proxy, TLS, or local-service state. */
requiresManagedTransport(model: Model): boolean;
/** Copies host-owned managed-transport state onto a projected model. */
inheritManagedTransport(source: Model, target: Model): Model;
/** Applies host-owned transcript replay and pairing rules. */
transformTransportMessages: AiTransformTransportMessages;
/** Registers a custom transport API with the host's stream error bridge. */
registerCustomApi(registry: ApiRegistry, api: Api, streamFn: StreamFn): boolean;
/** Prepares the provider-owned Google simple-completion alias when needed. */
prepareGoogleSimpleCompletionModel(registry: ApiRegistry, model: Model): Model;
/**
* Emits one transport diagnostic; build runs only when the host logs it and
* may return null to suppress the entry (e.g. de-duplication).
@@ -44,6 +170,35 @@ export interface AiTransportHost {
subsystem: string,
build: () => { message: string; data?: Record<string, unknown> } | null,
): void;
/** Emits an informational transport diagnostic through the host logger. */
logInfo(subsystem: string, message: string, data?: Record<string, unknown>): void;
/** Emits a warning through the host logger. */
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 false;
}
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 false;
}
const inertAiTransportHost: AiTransportHost = {
@@ -53,14 +208,71 @@ const inertAiTransportHost: AiTransportHost = {
redactToolPayloadText: (text) => text,
resolveOpenAIStrictToolSetting: (_model, options) =>
options?.supportsStrictMode ? false : undefined,
plugin: {
resolveProviderStream: () => undefined,
resolveTransportTurnState: () => undefined,
wrapSimpleCompletionStream: () => undefined,
createAnthropicVertexStream: () => {
throw new Error("Anthropic Vertex transport is not configured by the embedding host");
},
},
buildCopilotDynamicHeaders: () => ({}),
resolveProviderEndpointClass: () => "default",
resolveProviderRequestCapabilities: () => ({
endpointClass: "default",
knownProviderFamily: "",
supportsNativeStreamingUsageCompat: false,
supportsOpenAICompletionsStreamingUsageCompat: false,
usesExplicitProxyLikeEndpoint: false,
allowsAnthropicServiceTier: false,
}),
resolveProviderRequestHeaders: ({ providerHeaders, callerHeaders, precedence }) => ({
...(precedence === "caller-wins" ? providerHeaders : callerHeaders),
...(precedence === "caller-wins" ? callerHeaders : providerHeaders),
}),
resolveModelRequestTimeoutMs: () => undefined,
requiresManagedTransport: () => false,
inheritManagedTransport: (_source, target) => target,
transformTransportMessages: (messages) => messages,
registerCustomApi: queueCustomApiRegistration,
prepareGoogleSimpleCompletionModel: (_registry, model) => model,
logDebug: () => {},
logInfo: () => {},
logWarn: () => {},
};
let activeAiTransportHost = inertAiTransportHost;
/** Installs host implementations for the transport policy ports. */
export function configureAiTransportHost(host: Partial<AiTransportHost>): void {
activeAiTransportHost = { ...inertAiTransportHost, ...host };
activeAiTransportHost = {
...inertAiTransportHost,
...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). */
+22
View File
@@ -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";
@@ -1,13 +1,13 @@
import {
splitSystemPromptCacheBoundary,
stripSystemPromptCacheBoundary,
} from "@openclaw/ai/internal/shared";
} 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 "./provider-attribution.js";
import { resolveProviderRequestCapabilities } from "./host-policy.js";
/** @deprecated Anthropic-family provider payload helper; do not use from third-party plugins. */
type AnthropicServiceTier = "auto" | "standard_only";
@@ -1,25 +1,57 @@
import type { Model } from "@openclaw/llm-core";
/**
* Tests Anthropic Messages transport streaming.
* Covers request construction, SSE parsing, aborts, tool calls, usage, and
* provider transport hooks.
*/
import { expectDefined } from "@openclaw/normalization-core";
import type { Model } from "openclaw/plugin-sdk/llm";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { attachModelProviderRequestTransport } from "./provider-request-config.js";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { configureAiTransportHost, getAiTransportHost } from "../host.js";
const { buildGuardedModelFetchMock, guardedFetchMock } = vi.hoisted(() => ({
buildGuardedModelFetchMock: vi.fn(),
guardedFetchMock: vi.fn(),
}));
vi.mock("./provider-transport-fetch.js", () => ({
buildGuardedModelFetch: buildGuardedModelFetchMock,
parseRetryAfterSeconds: (headers: Headers) => {
const value = headers.get("retry-after");
return value && /^\d+$/.test(value) ? Number(value) : undefined;
},
}));
const coreTransportHost = getAiTransportHost();
function resolveTestEndpointClass(baseUrl?: string): string {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return "default";
}
try {
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
const hostname = url.hostname.toLowerCase();
if (hostname === "api.anthropic.com") {
return "anthropic-public";
}
if (hostname === "openrouter.ai") {
return "openrouter";
}
if (hostname === "api.xiaomimimo.com" || hostname.endsWith(".xiaomimimo.com")) {
return "xiaomi-native";
}
return "custom";
} catch {
return "invalid";
}
}
function redactTestSecrets<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => redactTestSecrets(item)) as T;
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
key === "key" ? "***" : redactTestSecrets(entry),
]),
) as T;
}
let createAnthropicMessagesTransportStreamFn: typeof import("./anthropic-transport-stream.js").createAnthropicMessagesTransportStreamFn;
@@ -27,7 +59,29 @@ type AnthropicMessagesModel = Model<"anthropic-messages">;
type AnthropicStreamFn = ReturnType<typeof createAnthropicMessagesTransportStreamFn>;
type AnthropicStreamContext = Parameters<AnthropicStreamFn>[1];
type AnthropicStreamOptions = Parameters<AnthropicStreamFn>[2];
type RequestTransportConfig = Parameters<typeof attachModelProviderRequestTransport>[1];
type RequestTransportConfig = {
proxy?: unknown;
tls?: unknown;
headers?: Record<string, string>;
allowPrivateNetwork?: boolean;
};
const MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL = Symbol.for(
"openclaw.modelProviderRequestTransport",
);
function attachModelProviderRequestTransport<TModel extends object>(
model: TModel,
request: RequestTransportConfig | undefined,
): TModel {
if (!request) {
return model;
}
const next = { ...model } as TModel & {
[MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL]?: RequestTransportConfig;
};
next[MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL] = request;
return next;
}
function createSseResponse(events: Record<string, unknown>[] = []): Response {
const body = serializeSseEvents(events);
@@ -267,6 +321,23 @@ describe("anthropic transport stream", () => {
buildGuardedModelFetchMock.mockReset();
guardedFetchMock.mockReset();
buildGuardedModelFetchMock.mockReturnValue(guardedFetchMock);
configureAiTransportHost({
...coreTransportHost,
buildModelFetch: buildGuardedModelFetchMock,
redactSecrets: redactTestSecrets,
resolveProviderEndpointClass: resolveTestEndpointClass,
resolveProviderRequestCapabilities: (input) => {
const endpointClass = resolveTestEndpointClass(input.baseUrl);
return {
endpointClass,
knownProviderFamily: endpointClass === "xiaomi-native" ? "xiaomi" : "",
supportsNativeStreamingUsageCompat: false,
supportsOpenAICompletionsStreamingUsageCompat: false,
usesExplicitProxyLikeEndpoint: endpointClass === "custom" || endpointClass === "invalid",
allowsAnthropicServiceTier: endpointClass === "anthropic-public",
};
},
});
guardedFetchMock.mockResolvedValue(createSseResponse());
});
@@ -274,6 +345,10 @@ describe("anthropic transport stream", () => {
vi.useRealTimers();
});
afterAll(() => {
configureAiTransportHost(coreTransportHost);
});
it("keeps aggregate cache billing buckets out of the context total", async () => {
guardedFetchMock.mockResolvedValueOnce(
createSseResponse([
@@ -3567,9 +3642,8 @@ describe("anthropic transport stream", () => {
(record) => record.type === "tool_result" && record.tool_use_id === "tool_1",
);
const blocks = Array.isArray(toolResult.content) ? toolResult.content : [];
const imageBlocks = blocks.filter((b: Record<string, unknown>) => b.type === "image");
const imageBlocks = blocks.filter((block: Record<string, unknown>) => block.type === "image");
expect(imageBlocks).toHaveLength(0);
// Text content is preserved as a plain string when images are dropped.
expect(toolResult.content).toContain("captured screen");
expect(toolResult.is_error).toBe(false);
});
@@ -1,3 +1,20 @@
import type {
AssistantMessageDiagnostic,
Context,
Model,
SimpleStreamOptions,
StreamFn,
ThinkingLevel,
Usage,
} from "@openclaw/llm-core";
import { toErrorObject } from "@openclaw/normalization-core/error-coercion";
/**
* Native Anthropic Messages streaming transport.
* Converts OpenClaw contexts/tools into Anthropic payloads, streams SSE events
* back into runtime output blocks, and applies provider request policy.
*/
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { getAiTransportHost } from "../host.js";
import {
ANTHROPIC_OMITTED_REASONING_TEXT,
ANTHROPIC_SERVER_SIDE_FALLBACK_BETA,
@@ -31,7 +48,7 @@ import {
type AnthropicProjectedToolChoice,
type AnthropicThinkingDisplay,
type AnthropicToolProjection,
} from "@openclaw/ai/internal/anthropic";
} from "../internal/anthropic.js";
import {
calculateCost,
clampThinkingLevel,
@@ -39,43 +56,23 @@ import {
getEnvApiKey,
notifyLlmRequestActivity,
parseStreamingJson,
} from "@openclaw/ai/internal/runtime";
} from "../internal/runtime.js";
import {
describeToolResultMediaPlaceholder,
extractToolResultBlockText,
extractToolResultText,
isImageWithMediaPayload,
} from "@openclaw/ai/internal/shared";
/**
* Native Anthropic Messages streaming transport.
* Converts OpenClaw contexts/tools into Anthropic payloads, streams SSE events
* back into runtime output blocks, and applies provider request policy.
*/
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { createAbortError as createNamedAbortError } from "../infra/abort-signal.js";
import { toErrorObject } from "../infra/errors.js";
import { readResponseTextSnippet } from "../infra/http-body.js";
import type {
AssistantMessageDiagnostic,
Context,
Model,
SimpleStreamOptions,
ThinkingLevel,
} from "../llm/types.js";
import "../llm/ai-transport-host.js";
import { looksLikeSecretSentinel, resolveSecretSentinel } from "../secrets/sentinel.js";
import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js";
} from "../internal/shared.js";
import {
applyAnthropicPayloadPolicyToParams,
resolveAnthropicPayloadPolicy,
} from "./anthropic-payload-policy.js";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./copilot-dynamic-headers.js";
import {
buildGuardedModelFetch,
resolveProviderEndpoint,
transformTransportMessages,
} from "./host-policy.js";
import { parseJsonObjectPreservingUnsafeIntegers } from "./json-unsafe-integers.js";
import { resolveProviderEndpoint } from "./provider-attribution.js";
import { unwrapModelHeaderSentinelsForProviderEgress } from "./provider-secret-egress.js";
import { buildGuardedModelFetch, parseRetryAfterSeconds } from "./provider-transport-fetch.js";
import type { StreamFn } from "./runtime/index.js";
import { transformTransportMessages } from "./transport-message-transform.js";
import {
coerceTransportToolCallArguments,
createEmptyTransportUsage,
@@ -87,7 +84,16 @@ import {
sanitizeNonEmptyTransportPayloadText,
sanitizeTransportPayloadText,
} from "./transport-stream-shared.js";
import type { ContextUsage } from "./usage.js";
import {
createAbortError as createNamedAbortError,
MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE,
parseRetryAfterSeconds,
readResponseTextSnippet,
resolveModelHeaderSentinels,
resolveSecretSentinel,
} from "./transport-utils.js";
type ContextUsage = NonNullable<Usage["contextUsage"]>;
const CLAUDE_CODE_VERSION = "2.1.75";
const CLAUDE_CODE_BILLING_SYSTEM_BLOCK = `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_VERSION}; cc_entrypoint=sdk-cli;`;
@@ -308,7 +314,7 @@ function adjustMaxTokensForThinking(params: {
function isAnthropicOAuthToken(apiKey: string): boolean {
// Auth routing may inspect the real shape, but guarded fetch still receives the sentinel.
const resolved = looksLikeSecretSentinel(apiKey) ? resolveSecretSentinel(apiKey) : apiKey;
const resolved = resolveSecretSentinel(apiKey);
return (resolved ?? apiKey).includes("sk-ant-oat");
}
@@ -926,10 +932,7 @@ function createAnthropicTransportClient(params: {
...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}),
},
model.headers,
buildCopilotDynamicHeaders({
messages: context.messages,
hasImages: hasCopilotVisionInput(context.messages),
}),
getAiTransportHost().buildCopilotDynamicHeaders(context.messages),
options?.headers,
),
fetch,
@@ -937,11 +940,7 @@ function createAnthropicTransportClient(params: {
isOAuthToken: false,
};
}
if (
usesFoundryBearerAuth(
unwrapModelHeaderSentinelsForProviderEgress(model, "Anthropic Foundry auth routing"),
)
) {
if (usesFoundryBearerAuth(resolveModelHeaderSentinels(model))) {
const betaFeatures = needsInterleavedBeta ? ["interleaved-thinking-2025-05-14"] : [];
return {
client: createAnthropicMessagesClient({
+70
View File
@@ -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,
);
}
@@ -4,9 +4,10 @@
* Model adapters share these helpers so payload, SSE, and transport diagnostics
* interpret OpenClaw debug environment variables consistently.
*/
import type { createSubsystemLogger } from "../logging/subsystem.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
type SubsystemLogger = {
info(message: string): void;
debug(message: string): void;
};
type ModelTransportDebugEnv = NodeJS.ProcessEnv;
@@ -4,9 +4,12 @@
* Provider transports use these helpers to derive OpenAI-compatible request
* behavior from endpoint attribution without scattering provider-specific flags.
*/
import type { Model } from "../llm/types.js";
import type { ProviderEndpointClass, ProviderRequestCapabilities } from "./provider-attribution.js";
import { resolveProviderRequestCapabilities } from "./provider-attribution.js";
import type { Model } from "@openclaw/llm-core";
import type { AiProviderRequestCapabilities, AiProviderRequestPolicyInput } from "../host.js";
import { resolveProviderRequestCapabilities } from "./host-policy.js";
type ProviderEndpointClass = string;
type ProviderRequestCapabilities = AiProviderRequestCapabilities;
type OpenAICompletionsCompatDefaultsInput = {
provider?: string;
@@ -149,8 +152,11 @@ export function detectOpenAICompletionsCompat(
model: Pick<Model<"openai-completions">, "provider" | "baseUrl" | "id"> & {
compat?: { supportsStore?: boolean } | null;
},
resolveCapabilities: (
input: AiProviderRequestPolicyInput,
) => ProviderRequestCapabilities = resolveProviderRequestCapabilities,
): DetectedOpenAICompletionsCompat {
const capabilities = resolveProviderRequestCapabilities({
const capabilities = resolveCapabilities({
provider: model.provider,
api: "openai-completions",
baseUrl: model.baseUrl,
@@ -1,4 +1,9 @@
import { randomUUID } from "node:crypto";
import type { Context, Model, StreamFn } from "@openclaw/llm-core";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import OpenAI from "openai";
import type { ChatCompletionChunk } from "openai/resources/chat/completions.js";
import {
convertMessages,
isOpenAIGpt54MiniModel,
@@ -10,7 +15,7 @@ import {
reconcileOpenAICompletionsToolChoice,
resolveOpenAIReasoningEffortForModel,
type OpenAIReasoningEffort,
} from "@openclaw/ai/internal/openai";
} from "../internal/openai.js";
import {
applyProviderReportedUsageCost,
calculateCost,
@@ -21,23 +26,16 @@ import {
getFirstStreamEventTimeoutMs,
parseStreamingJson,
withFirstStreamEventTimeout,
} from "@openclaw/ai/internal/runtime";
import { stripSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import OpenAI from "openai";
import type { ChatCompletionChunk } from "openai/resources/chat/completions.js";
import type { Context, Model } from "../llm/types.js";
import "../llm/ai-transport-host.js";
import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js";
import {
isGoogleGemini3FlashModel,
isGoogleGemini3ProModel,
} from "../plugin-sdk/provider-stream-shared.js";
import { CHARS_PER_TOKEN_ESTIMATE, estimateStringChars } from "../utils/cjk-chars.js";
} from "../internal/runtime.js";
import { stripSystemPromptCacheBoundary } from "../internal/shared.js";
import { createAssistantMessageEventStream } from "../utils/event-stream.js";
import { createDeepSeekTextFilter } from "./deepseek-text-filter.js";
import {
buildGuardedModelFetch,
resolveOpenAIStrictToolSetting,
resolveProviderEndpoint,
} from "./host-policy.js";
import { resolveMaxTokensParam } from "./model-max-tokens-params.js";
import { supportsModelTools } from "./model-tool-support.js";
import { emitModelTransportDebug } from "./model-transport-debug.js";
import { hasOpenAICompatibleConversationTurn } from "./openai-compatible-conversation-turn.js";
import { detectOpenAICompletionsCompat } from "./openai-completions-compat.js";
@@ -45,7 +43,6 @@ import {
flattenCompletionMessagesToStringContent,
stripCompletionMessagesToRoleContent,
} from "./openai-completions-string-content.js";
import { resolveOpenAIStrictToolSetting } from "./openai-strict-tool-setting.js";
import {
assertCodeModeResponsesToolSurface,
buildOpenAIClientHeaders,
@@ -67,10 +64,14 @@ import {
type OpenAICompletionsOptions,
type OpenAIModeModel,
} from "./openai-transport-shared.js";
import { resolveProviderEndpoint } from "./provider-attribution.js";
import { buildGuardedModelFetch } from "./provider-transport-fetch.js";
import type { StreamFn } from "./runtime/index.js";
import { failTransportStream, finalizeTransportStream } from "./transport-stream-shared.js";
import {
CHARS_PER_TOKEN_ESTIMATE,
estimateStringChars,
isGoogleGemini3FlashModel,
isGoogleGemini3ProModel,
supportsModelTools,
} from "./transport-utils.js";
function hasToolHistory(messages: Context["messages"]): boolean {
return messages.some(
@@ -1721,8 +1722,7 @@ export function buildOpenAICompletionsParams(
// canonical prompt_cache_retention value alongside the cache key so
// OpenAI-compatible completions backends (oMLX, llama.cpp, official
// OpenAI, etc.) can honor the 24h prefix-cache lifetime. Without this
// the key reaches the wire but the retention preference is silently
// dropped (issue #81281).
// the key reaches the wire but the retention preference is silently dropped.
if (cacheRetention === "long" && compat.supportsLongCacheRetention) {
params.prompt_cache_retention = "24h";
}
@@ -1892,10 +1892,10 @@ function parseTransportChunkUsage(
},
model: Model,
): MutableAssistantOutput["usage"] {
const cachedTokens = rawUsage.prompt_tokens_details?.cached_tokens || 0;
// OpenRouter reports cache writes separately inside prompt totals. Keep read/write
// buckets out of input so normalized prompt buckets stay disjoint.
const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0;
const cachedTokens = rawUsage.prompt_tokens_details?.cached_tokens || 0;
const promptTokens = rawUsage.prompt_tokens || 0;
const input = Math.max(0, promptTokens - cachedTokens - cacheWriteTokens);
const outputTokens = rawUsage.completion_tokens || 0;
@@ -1,12 +1,11 @@
import { supportsOpenAIReasoningEffort } from "@openclaw/ai/internal/openai";
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 { parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
import { asBoolean } from "../utils/boolean.js";
import { supportsOpenAIReasoningEffort } from "../internal/openai.js";
type OpenAIResponsesPayloadModel = {
api?: unknown;
@@ -72,6 +71,7 @@ const OPENAI_RESPONSES_APIS = new Set([
"azure-openai-responses",
"openai-chatgpt-responses",
"openclaw-openai-responses-transport",
"openclaw-openai-chatgpt-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]"]);
@@ -221,7 +221,8 @@ function readCompatPayloadBoolean(
if (!compat || typeof compat !== "object") {
return undefined;
}
return asBoolean((compat as Record<string, unknown>)[key]);
const value = (compat as Record<string, unknown>)[key];
return typeof value === "boolean" ? value : undefined;
}
function resolveOpenAIResponsesPayloadCapabilities(
@@ -257,12 +258,14 @@ function resolveOpenAIResponsesPayloadCapabilities(
endpointClass === "openai-public") ||
(isOpenAIProvider &&
(api === "openai-chatgpt-responses" ||
api === "openclaw-openai-chatgpt-responses-transport" ||
api === "openai-responses" ||
api === "openclaw-openai-responses-transport") &&
endpointClass === "openai"),
allowsResponsesStore:
supportsResponsesStoreField &&
api !== "openai-chatgpt-responses" &&
api !== "openclaw-openai-chatgpt-responses-transport" &&
provider !== undefined &&
OPENAI_RESPONSES_PROVIDERS.has(provider) &&
usesKnownNativeOpenAIEndpoint,
@@ -1,8 +1,12 @@
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 `@openclaw/ai/internal/openai`, shared with the
// 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 {
@@ -10,12 +14,8 @@ import {
readResponsesReasoningTokens,
resolveResponsesTerminalStopReason,
type ResponsesTerminalUsagePayload,
} from "@openclaw/ai/internal/openai";
import { calculateCost } from "@openclaw/ai/internal/runtime";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type OpenAI from "openai";
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
import type { Model } from "../llm/types.js";
} 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 {
@@ -2,6 +2,22 @@
* OpenAI Responses transport for Azure variants, replay, reasoning, and payload policy.
*/
import { randomUUID } from "node:crypto";
import type { Api, Context, Model, StreamFn } from "@openclaw/llm-core";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import OpenAI, { AzureOpenAI } from "openai";
import type {
FunctionTool,
ResponseCreateParamsStreaming,
ResponseFormatTextConfig,
ResponseFunctionCallOutputItemList,
ResponseInput,
ResponseInputItem,
ResponseInputMessageContentList,
ResponseOutputMessage,
ResponseReasoningItem,
} from "openai/resources/responses/responses.js";
import { getAiTransportHost } from "../host.js";
import {
createResponsesToolCallTracker,
isOpenAICompatibleAzureResponsesBaseUrl,
@@ -19,7 +35,7 @@ import {
type OpenAIReasoningEffort,
type OpenAIToolProjection,
type ResponsesToolCallState,
} from "@openclaw/ai/internal/openai";
} from "../internal/openai.js";
import {
createFirstStreamEventAbortController,
getEnvApiKey,
@@ -27,35 +43,19 @@ import {
getFirstStreamEventTimeoutMs,
parseStreamingJson,
withFirstStreamEventTimeout,
} from "@openclaw/ai/internal/runtime";
} from "../internal/runtime.js";
import {
describeToolResultMediaPlaceholder,
extractToolResultText,
isImageWithMediaPayload,
stripSystemPromptCacheBoundary,
} from "@openclaw/ai/internal/shared";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import OpenAI, { AzureOpenAI } from "openai";
import type {
FunctionTool,
ResponseCreateParamsStreaming,
ResponseFormatTextConfig,
ResponseFunctionCallOutputItemList,
ResponseInput,
ResponseInputItem,
ResponseInputMessageContentList,
ResponseOutputMessage,
ResponseReasoningItem,
} from "openai/resources/responses/responses.js";
import { sha256HexPrefix } from "../infra/crypto-digest.js";
import type { Api, Context, Model } from "../llm/types.js";
import "../llm/ai-transport-host.js";
import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js";
import { redactIdentifier } from "../logging/redact-identifier.js";
import { redactSensitiveText } from "../logging/redact.js";
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
import { resolveProviderTransportTurnStateWithPlugin } from "../plugins/provider-runtime.js";
} from "../internal/shared.js";
import { createAssistantMessageEventStream } from "../utils/event-stream.js";
import {
buildGuardedModelFetch,
resolveOpenAIStrictToolSetting,
transformTransportMessages,
} from "./host-policy.js";
import {
emitModelTransportDebug,
resolveModelPayloadDebugMode,
@@ -68,7 +68,6 @@ import {
} from "./openai-responses-payload-policy.js";
import { resolveReplayableResponsesMessageId } from "./openai-responses-replay.js";
import { recordResponsesTerminalOutcome } from "./openai-responses-terminal-outcome.js";
import { resolveOpenAIStrictToolSetting } from "./openai-strict-tool-setting.js";
import {
assertCodeModeResponsesToolSurface,
buildOpenAIClientHeaders,
@@ -91,16 +90,14 @@ import {
type MutableAssistantOutput,
type OpenAIModeModel,
} from "./openai-transport-shared.js";
import { buildGuardedModelFetch } from "./provider-transport-fetch.js";
import { sanitizeResponsesImagePayload } from "./responses-image-payload-sanitizer.js";
import type { StreamFn } from "./runtime/index.js";
import { transformTransportMessages } from "./transport-message-transform.js";
import {
assignTransportErrorDetails,
mergeTransportMetadata,
sanitizeNonEmptyTransportPayloadText,
sanitizeTransportPayloadText,
} from "./transport-stream-shared.js";
import { redactIdentifier, redactSensitiveText, sha256HexPrefix } from "./transport-utils.js";
const DEFAULT_AZURE_OPENAI_API_VERSION = "preview";
const OPENAI_CODEX_RESPONSES_EMPTY_INPUT_TEXT = " ";
@@ -1787,14 +1784,14 @@ function resolveProviderTransportTurnState(
normalizedProvider === "openai" ||
normalizedProvider === "azure-openai" ||
normalizedProvider === "azure-openai-responses";
return resolveProviderTransportTurnStateWithPlugin({
return getAiTransportHost().plugin.resolveTransportTurnState({
provider: model.provider,
modelId: model.id,
allowRuntimePluginLoad,
context: {
provider: model.provider,
modelId: model.id,
model: model as ProviderRuntimeModel,
model,
sessionId: params.sessionId,
turnId: params.turnId,
attempt: params.attempt,
@@ -1,19 +1,17 @@
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 "@openclaw/ai/internal/openai";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { sha256Hex } from "../infra/crypto-digest.js";
import type { Context, Model } from "../llm/types.js";
import { isCodeModeModelVisibleToolName } from "./code-mode-control-tools.js";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./copilot-dynamic-headers.js";
} 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 { log, type OpenAIModeModel } from "./openai-transport-shared.js";
import { resolveProviderRequestPolicyConfig } from "./provider-request-config.js";
import { resolveModelRequestTimeoutMs } from "./provider-transport-fetch.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"]);
@@ -119,27 +117,30 @@ export function resolveOpenAIStrictToolFlagWithDiagnostics(
context: { transport: "responses" | "completions"; model: OpenAIModeModel },
): boolean | undefined {
const strict = resolveOpenAIProjectedToolsStrictToolFlag(projection, strictSetting);
if (strictSetting === true && strict === false && log.isEnabled("debug", "any")) {
if (strictSetting === true && strict === false) {
const diagnostics = findOpenAIStrictToolProjectionDiagnostics(projection);
if (!shouldLogOpenAIStrictToolDowngradeDiagnostic(diagnostics, context)) {
return strict;
}
const sample = diagnostics.slice(0, 5).map((entry) => ({
tool: entry.toolName ?? `tool[${entry.toolIndex}]`,
violations: entry.violations.slice(0, 8),
}));
log.debug(
`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`,
{
transport: context.transport,
provider: context.model.provider,
model: context.model.id,
incompatibleToolCount: diagnostics.length,
sample,
},
);
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;
}
@@ -148,7 +149,7 @@ 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")
model.api === "openclaw-openai-chatgpt-responses-transport")
);
}
@@ -192,10 +193,7 @@ export function buildOpenAIClientHeaders(
if (model.provider === "github-copilot") {
Object.assign(
providerHeaders,
buildCopilotDynamicHeaders({
messages: context.messages,
hasImages: hasCopilotVisionInput(context.messages),
}),
getAiTransportHost().buildCopilotDynamicHeaders(context.messages),
);
}
const callerHeaders = { ...optionHeaders, ...turnHeaders };
@@ -1,18 +1,27 @@
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 "@openclaw/ai/internal/openai";
import type { ModelCompatConfig } from "../config/types.models.js";
import type { Api, Model, Usage } from "../llm/types.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
} 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 = createSubsystemLogger("openai-transport");
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;
@@ -42,8 +51,13 @@ export type OpenAICompletionsOptions = BaseOpenAIStreamOptions & {
reasoningEffort?: OpenAIReasoningEffort;
};
type OpenAIModeCompatInput = Omit<ModelCompatConfig, "thinkingFormat"> & {
type OpenAIModeCompatInput = Omit<OpenAICompletionsCompat, "thinkingFormat"> & {
thinkingFormat?: string;
requiresStringContent?: boolean;
strictMessageKeys?: boolean;
unsupportedToolSchemaKeywords?: unknown;
omitEmptyArrayItems?: unknown;
visibleReasoningDetailTypes?: string[];
};
export type OpenAIModeModel = Omit<Model, "compat"> & {
@@ -1,8 +1,8 @@
// Verifies transport-aware model stream aliases and fail-closed boundaries.
import type { Api, Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it } from "vitest";
import { attachModelProviderLocalService } from "./provider-local-service.js";
import { attachModelProviderRequestTransport } from "./provider-request-config.js";
import type { Api, Model } from "@openclaw/llm-core";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { configureAiTransportHost, getAiTransportHost } from "../host.js";
import { isOpenAICodexResponsesModel } from "./openai-transport-params.js";
import {
buildTransportAwareSimpleStreamFn,
createBoundaryAwareStreamFnForModel,
@@ -12,6 +12,37 @@ import {
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),
inheritManagedTransport: (source, target) => {
if (managedTransportModels.has(source)) {
managedTransportModels.add(target);
}
return target;
},
});
});
afterAll(() => {
configureAiTransportHost(initialHost);
});
function buildModel<TApi extends Api>(
api: TApi,
params: {
@@ -51,7 +82,7 @@ describe("provider transport stream contracts", () => {
provider: "openai",
id: "codex-mini-latest",
baseUrl: "https://chatgpt.com/backend-api",
alias: "openclaw-openai-responses-transport",
alias: "openclaw-openai-chatgpt-responses-transport",
},
{
api: "openai-completions" as const,
@@ -110,9 +141,47 @@ describe("provider transport stream contracts", () => {
expect(preparedModel.api).toBe(testCase.alias);
expect(preparedModel.provider).toBe(testCase.provider);
expect(preparedModel.id).toBe(testCase.id);
expect(managedTransportModels.has(preparedModel)).toBe(true);
}
});
it("keeps public and Codex Responses transport aliases distinguishable", () => {
const publicModel = attachModelProviderRequestTransport(
buildModel("openai-responses", {
id: "gpt-5.4",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
}),
{ proxy: { mode: "env-proxy" } },
);
const codexModel = attachModelProviderRequestTransport(
buildModel("openai-chatgpt-responses", {
id: "codex-mini-latest",
provider: "openai",
baseUrl: "https://chatgpt.com/backend-api",
}),
{ proxy: { mode: "env-proxy" } },
);
expect(isOpenAICodexResponsesModel(prepareTransportAwareSimpleModel(publicModel))).toBe(false);
expect(isOpenAICodexResponsesModel(prepareTransportAwareSimpleModel(codexModel))).toBe(true);
});
it("fails closed when a required Google transport is unavailable", () => {
const model = attachModelProviderRequestTransport(
buildModel("google-generative-ai", {
id: "gemini-3.1-pro-preview",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
}),
{ proxy: { mode: "env-proxy" } },
);
expect(() => createTransportAwareStreamFnForModel(model)).toThrow(
'Managed transport stream is unavailable for api "google-generative-ai"',
);
});
it("fails closed when unsupported apis carry transport overrides", () => {
const model = attachModelProviderRequestTransport(
buildModel("ollama", {
@@ -3,18 +3,14 @@
*
* Routes models that need OpenClaw-managed proxy/TLS/local-service semantics onto built-in transport implementations.
*/
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { Api, Model } from "../llm/types.js";
import { resolveProviderStreamFn } from "../plugins/provider-runtime.js";
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,
createOpenAICompletionsTransportStreamFn,
createOpenAIResponsesTransportStreamFn,
} from "./openai-transport-stream.js";
import { getModelProviderLocalService } from "./provider-local-service.js";
import { getModelProviderRequestTransport } from "./provider-request-config.js";
import type { StreamFn } from "./runtime/index.js";
} from "./openai-responses-transport.js";
const SUPPORTED_TRANSPORT_APIS = new Set<Api>([
"openai-responses",
@@ -27,7 +23,7 @@ const SUPPORTED_TRANSPORT_APIS = new Set<Api>([
const SIMPLE_TRANSPORT_API_ALIAS: Record<string, Api> = {
"openai-responses": "openclaw-openai-responses-transport",
"openai-chatgpt-responses": "openclaw-openai-responses-transport",
"openai-chatgpt-responses": "openclaw-openai-chatgpt-responses-transport",
"openai-completions": "openclaw-openai-completions-transport",
"azure-openai-responses": "openclaw-azure-openai-responses-transport",
"anthropic-messages": "openclaw-anthropic-messages-transport",
@@ -35,7 +31,7 @@ const SIMPLE_TRANSPORT_API_ALIAS: Record<string, Api> = {
};
type ProviderTransportStreamContext = {
cfg?: OpenClawConfig;
cfg?: unknown;
agentDir?: string;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
@@ -46,7 +42,7 @@ function createProviderOwnedGoogleTransportStreamFn(
ctx?: ProviderTransportStreamContext,
): StreamFn | undefined {
return (
resolveProviderStreamFn({
getAiTransportHost().plugin.resolveProviderStream({
provider: model.provider,
config: ctx?.cfg,
workspaceDir: ctx?.workspaceDir,
@@ -60,7 +56,7 @@ function createProviderOwnedGoogleTransportStreamFn(
model,
},
}) ??
resolveProviderStreamFn({
getAiTransportHost().plugin.resolveProviderStream({
provider: "google",
config: ctx?.cfg,
workspaceDir: ctx?.workspaceDir,
@@ -100,8 +96,7 @@ function createSupportedTransportStreamFn(
}
function hasOpenClawTransportRequirement(model: Model): boolean {
const request = getModelProviderRequestTransport(model);
return Boolean(request?.proxy || request?.tls || getModelProviderLocalService(model));
return getAiTransportHost().requiresManagedTransport(model);
}
/** Returns whether OpenClaw has a managed transport implementation for this API. */
@@ -127,7 +122,11 @@ export function createTransportAwareStreamFnForModel(
`Model-provider request.proxy/request.tls/localService is not yet supported for api "${model.api}"`,
);
}
return createSupportedTransportStreamFn(model, ctx);
const streamFn = createSupportedTransportStreamFn(model, ctx);
if (!streamFn) {
throw new Error(`Managed transport stream is unavailable for api "${model.api}"`);
}
return streamFn;
}
/** Creates a managed OpenClaw transport stream for explicit fallback/runtime callers. */
@@ -166,10 +165,10 @@ export function prepareTransportAwareSimpleModel<TApi extends Api>(
if (!streamFn || !alias) {
return model;
}
return {
return getAiTransportHost().inheritManagedTransport(model, {
...model,
api: alias,
};
});
}
export function buildTransportAwareSimpleStreamFn(
@@ -1,12 +1,10 @@
import { createApiRegistry, type ApiRegistry } from "@openclaw/ai";
import { createAssistantMessageEventStream } from "@openclaw/llm-core";
import type { Api, 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 type { Model } from "openclaw/plugin-sdk/llm";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createMoonshotThinkingWrapper } from "../llm/providers/stream-wrappers/moonshot-thinking.js";
import { mintSecretSentinel } from "../secrets/sentinel.js";
import type { StreamFn } from "./runtime/index.js";
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();
@@ -18,19 +16,11 @@ const createTransportAwareStreamFnForModel = vi.fn();
const prepareTransportAwareSimpleModel = vi.fn();
const resolveTransportAwareSimpleApi = vi.fn();
const prepareGoogleSimpleCompletionModel = vi.fn((_registry: unknown, model: unknown) => model);
const inheritManagedTransport = vi.fn((_source: Model, target: Model) => target);
const pluginStreamFn = vi.fn(() => "plugin-stream-result" as never);
vi.mock("./anthropic-vertex-stream.js", () => ({
createAnthropicVertexStreamFnForModel,
}));
vi.mock("./custom-api-registry.js", () => ({
ensureCustomApiRegistered,
}));
vi.mock("./google-simple-completion-stream.js", () => ({
prepareGoogleSimpleCompletionModel,
}));
const TEST_SECRET = "ollama-provider-secret";
const TEST_SECRET_SENTINEL = "test-secret-sentinel";
const initialHost = getAiTransportHost();
vi.mock("./provider-transport-stream.js", () => ({
buildTransportAwareSimpleStreamFn,
@@ -40,17 +30,6 @@ vi.mock("./provider-transport-stream.js", () => ({
resolveTransportAwareSimpleApi,
}));
vi.mock("../plugins/provider-runtime.js", async () => {
const actual = await vi.importActual<typeof import("../plugins/provider-runtime.js")>(
"../plugins/provider-runtime.js",
);
return {
...actual,
resolveProviderStreamFn,
wrapProviderSimpleCompletionStreamFn,
};
});
let prepareModelForSimpleCompletionImpl: typeof import("./simple-completion-transport.js").prepareModelForSimpleCompletion;
let apiRegistry: ApiRegistry;
const SIMPLE_COMPLETION_SOURCE_ID = "test:simple-completion-transport";
@@ -78,6 +57,16 @@ describe("prepareModelForSimpleCompletion", () => {
apiRegistry = createApiRegistry();
createAnthropicVertexStreamFnForModel.mockReset();
ensureCustomApiRegistered.mockReset();
ensureCustomApiRegistered.mockImplementation(
(registry: ApiRegistry, api: Api, _streamFn: StreamFn) => {
if (registry.getApiProvider(api)) {
return false;
}
const stream = () => createAssistantMessageEventStream();
registry.registerApiProvider({ api, stream, streamSimple: stream });
return true;
},
);
resolveProviderStreamFn.mockReset();
pluginStreamFn.mockClear();
wrapProviderSimpleCompletionStreamFn.mockReset();
@@ -87,6 +76,7 @@ describe("prepareModelForSimpleCompletion", () => {
prepareTransportAwareSimpleModel.mockReset();
resolveTransportAwareSimpleApi.mockReset();
prepareGoogleSimpleCompletionModel.mockReset();
inheritManagedTransport.mockClear();
createAnthropicVertexStreamFnForModel.mockReturnValue("vertex-stream");
resolveProviderStreamFn.mockReturnValue(pluginStreamFn);
wrapProviderSimpleCompletionStreamFn.mockReturnValue(undefined);
@@ -96,6 +86,25 @@ describe("prepareModelForSimpleCompletion", () => {
prepareTransportAwareSimpleModel.mockImplementation((model) => model);
resolveTransportAwareSimpleApi.mockReturnValue(undefined);
prepareGoogleSimpleCompletionModel.mockImplementation((_registry, model) => model);
configureAiTransportHost({
plugin: {
resolveProviderStream: resolveProviderStreamFn,
resolveTransportTurnState: () => undefined,
wrapSimpleCompletionStream: wrapProviderSimpleCompletionStreamFn,
createAnthropicVertexStream: createAnthropicVertexStreamFnForModel,
},
registerCustomApi: ensureCustomApiRegistered,
inheritManagedTransport,
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", () => {
@@ -113,8 +122,14 @@ describe("prepareModelForSimpleCompletion", () => {
},
SIMPLE_COMPLETION_SOURCE_ID,
);
wrapProviderSimpleCompletionStreamFn.mockImplementationOnce(({ context }) =>
createMoonshotThinkingWrapper(context.streamFn),
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",
@@ -136,6 +151,7 @@ describe("prepareModelForSimpleCompletion", () => {
expect(result.api).toBe(
"openclaw-provider-simple:moonshot:kimi-k2.7-code:moonshot-simple-source:https%3A%2F%2Fapi.moonshot.ai%2Fv1",
);
expect(inheritManagedTransport).toHaveBeenCalledWith(model, result);
expect(wrapProviderSimpleCompletionStreamFn).toHaveBeenCalledWith(
expect.objectContaining({
provider: "moonshot",
@@ -156,8 +172,8 @@ describe("prepareModelForSimpleCompletion", () => {
});
it("registers the configured Ollama transport and keeps the original api", () => {
const secret = "ollama-provider-secret";
const sentinel = mintSecretSentinel(secret, { label: "model-auth:ollama" });
const secret = TEST_SECRET;
const sentinel = TEST_SECRET_SENTINEL;
const model: Model<"ollama"> = {
id: "llama3",
name: "Llama 3",
@@ -171,7 +187,7 @@ describe("prepareModelForSimpleCompletion", () => {
maxTokens: 4096,
headers: { Authorization: `Bearer ${sentinel}` },
};
const cfg: OpenClawConfig = {
const cfg = {
models: {
providers: {
ollama: {
@@ -250,6 +266,7 @@ describe("prepareModelForSimpleCompletion", () => {
...model,
api: "openclaw-anthropic-vertex-simple:https%3A%2F%2Fus-central1-aiplatform.googleapis.com",
});
expect(inheritManagedTransport).toHaveBeenCalledWith(model, result);
});
it("uses a transport-aware custom api alias when llm request transport overrides are present", () => {
@@ -383,7 +400,9 @@ describe("prepareModelForSimpleCompletion", () => {
resolveProviderStreamFn.mockReturnValueOnce(undefined);
createOpenClawTransportStreamFnForModel.mockReturnValueOnce("codex-transport-stream");
resolveTransportAwareSimpleApi.mockReturnValueOnce("openclaw-openai-responses-transport");
resolveTransportAwareSimpleApi.mockReturnValueOnce(
"openclaw-openai-chatgpt-responses-transport",
);
const result = prepareModelForSimpleCompletion({ model });
@@ -398,15 +417,40 @@ describe("prepareModelForSimpleCompletion", () => {
);
expect(ensureCustomApiRegistered).toHaveBeenCalledWith(
apiRegistry,
"openclaw-openai-responses-transport",
"openclaw-openai-chatgpt-responses-transport",
"codex-transport-stream",
);
expect(result).toEqual({
...model,
baseUrl: expectedBaseUrl,
api: "openclaw-openai-responses-transport",
api: "openclaw-openai-chatgpt-responses-transport",
});
expect(prepareTransportAwareSimpleModel).not.toHaveBeenCalled();
},
);
it.each([
"https://proxy.example.test/openai?token=x",
"https://proxy.example.test/openai#route",
"https://proxy.example.test/openai?",
"https://proxy.example.test/openai#",
])("rejects Codex proxy base URLs with query or fragment components: %s", (baseUrl) => {
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);
expect(() => prepareModelForSimpleCompletion({ model })).toThrow(
"OpenAI Codex Responses baseUrl must not include query parameters or fragments",
);
});
});
@@ -0,0 +1,247 @@
/**
* 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:";
const INVALID_CODEX_BASE_URL_MESSAGE =
"OpenAI Codex Responses baseUrl must not include query parameters or fragments";
function registerCustomApi(registry: ApiRegistry, api: Api, streamFn: StreamFn): boolean {
getAiTransportHost().registerCustomApi(registry, api, streamFn);
return registry.getApiProvider(api) !== undefined;
}
function projectModel(model: Model, patch: Partial<Model>): Model {
return getAiTransportHost().inheritManagedTransport(model, { ...model, ...patch });
}
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() || "https://chatgpt.com/backend-api";
try {
const parsed = new URL(normalized);
const pathname = parsed.pathname.replace(/\/+$/u, "");
const path = pathname.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, "");
}
if (normalized.includes("?") || normalized.includes("#")) {
throw new Error(INVALID_CODEX_BASE_URL_MESSAGE);
}
parsed.pathname = path.endsWith("/codex/responses")
? pathname.slice(0, -"/responses".length)
: path.endsWith("/codex")
? pathname
: `${pathname}/codex`;
return parsed.toString();
} catch (error) {
if (error instanceof Error && error.message === INVALID_CODEX_BASE_URL_MESSAGE) {
throw error;
}
// Keep non-URL custom values on the same suffix contract transport callers accept.
}
if (normalized.includes("?") || normalized.includes("#")) {
throw new Error(INVALID_CODEX_BASE_URL_MESSAGE);
}
const path = normalized.replace(/\/+$/u, "");
if (path.endsWith("/codex/responses")) {
return path.slice(0, -"/responses".length);
}
return path.endsWith("/codex") ? path : `${path}/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(projectModel(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);
return registerCustomApi(registry, api, streamFn) ? projectModel(model, { api }) : model;
}
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 = projectModel(model, {
baseUrl: normalizeCodexResponsesBaseUrlForOpenAISdk(model.baseUrl),
});
const api = resolveTransportAwareSimpleApi(model.api);
const streamFn = createOpenClawTransportStreamFnForModel(transportModel, { cfg });
if (!api || !streamFn) {
return undefined;
}
if (!registerCustomApi(registry, api, streamFn)) {
return undefined;
}
return projectModel(transportModel, { api });
}
function resolveModelHeaderSentinels<TApi extends Api>(model: Model<TApi>): Model<TApi> {
const headers = resolveAiTransportHeaderSentinels(model.headers);
return headers === model.headers ? model : (projectModel(model, { headers }) as Model<TApi>);
}
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;
return streamFn && registerCustomApi(params.apiRegistry, params.model.api, streamFn)
? streamFn
: undefined;
}
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 && 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);
const host = getAiTransportHost();
const streamFn = host.plugin.createAnthropicVertexStream(model);
if (registerCustomApi(apiRegistry, api, streamFn)) {
const transportModel = projectModel(model, { api });
return applyProviderSimpleCompletionWrapper(apiRegistry, transportModel, cfg);
}
}
return applyProviderSimpleCompletionWrapper(apiRegistry, model, cfg);
}
@@ -3,11 +3,13 @@
*
* Sanitizes provider payloads, merges metadata, and formats streamed assistant events.
*/
import { sanitizeSurrogates } from "@openclaw/ai/internal/shared";
import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js";
import { redactSensitiveText } from "../logging/redact.js";
import { truncateErrorDetail } from "./provider-http-errors.js";
import type { ContextUsage } from "./usage.js";
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;
@@ -210,7 +212,8 @@ function normalizeTransportErrorBody(value: unknown): string | undefined {
if (!text?.trim()) {
return undefined;
}
return truncateErrorDetail(redactSensitiveText(text), 500);
const redacted = redactSensitiveText(text);
return redacted.length > 500 ? `${truncateUtf16Safe(redacted, 499)}` : redacted;
}
function extractTransportErrorDetails(error: unknown): TransportErrorDetails {
@@ -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;
}
@@ -84,6 +84,7 @@ const REQUIRED_BUNDLED_WORKSPACE_RUNTIME_ENTRIES = new Map([
[
{ specifier: "@openclaw/ai", entry: "dist/index.mjs" },
{ specifier: "@openclaw/ai/providers", entry: "dist/providers.mjs" },
{ specifier: "@openclaw/ai/transports", entry: "dist/transports.mjs" },
{
specifier: "@openclaw/ai/internal/runtime",
entry: "dist/internal/runtime.mjs",
+8 -1
View File
@@ -79,9 +79,16 @@ export const EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS = {
"@openclaw/ai/diagnostics": ["../dist/plugin-sdk/packages/ai/src/utils/diagnostics.d.ts"],
"@openclaw/ai/event-stream": ["../dist/plugin-sdk/packages/ai/src/utils/event-stream.d.ts"],
"@openclaw/ai/providers": ["../dist/plugin-sdk/packages/ai/src/providers.d.ts"],
"@openclaw/ai/transports": ["../dist/plugin-sdk/packages/ai/src/transports.d.ts"],
"@openclaw/ai/types": ["../dist/plugin-sdk/packages/ai/src/types.d.ts"],
"@openclaw/ai/validation": ["../dist/plugin-sdk/packages/ai/src/validation.d.ts"],
"@openclaw/ai/internal/*": ["../dist/plugin-sdk/packages/ai/src/internal/*.d.ts"],
"@openclaw/ai/internal/anthropic": ["../dist/plugin-sdk/packages/ai/src/internal/anthropic.d.ts"],
"@openclaw/ai/internal/openai": ["../dist/plugin-sdk/packages/ai/src/internal/openai.d.ts"],
"@openclaw/ai/internal/retry-after": [
"../dist/plugin-sdk/packages/ai/src/internal/retry-after.d.ts",
],
"@openclaw/ai/internal/runtime": ["../dist/plugin-sdk/packages/ai/src/internal/runtime.d.ts"],
"@openclaw/ai/internal/shared": ["../dist/plugin-sdk/packages/ai/src/internal/shared.d.ts"],
"@openclaw/llm-core": ["../dist/plugin-sdk/packages/llm-core/src/index.d.ts"],
"@openclaw/llm-core/diagnostics": [
"../dist/plugin-sdk/packages/llm-core/src/utils/diagnostics.d.ts",
+103
View File
@@ -0,0 +1,103 @@
import {
configureAiTransportHost,
getAiTransportHost,
type AiProviderRequestCapabilities,
} from "@openclaw/ai";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import "../llm/ai-transport-host.js";
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
import {
resolveProviderStreamFn,
resolveProviderTransportTurnStateWithPlugin,
wrapProviderSimpleCompletionStreamFn,
} from "../plugins/provider-runtime.js";
import { createAnthropicVertexStreamFnForModel } from "./anthropic-vertex-stream.js";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./copilot-dynamic-headers.js";
import { ensureCustomApiRegistered } from "./custom-api-registry.js";
import { prepareGoogleSimpleCompletionModel } from "./google-simple-completion-stream.js";
import {
resolveProviderRequestCapabilities,
resolveProviderEndpoint,
} from "./provider-attribution.js";
import {
attachModelProviderLocalService,
getModelProviderLocalService,
} from "./provider-local-service.js";
import {
attachModelProviderRequestTransport,
getModelProviderRequestTransport,
resolveProviderRequestPolicyConfig,
} from "./provider-request-config.js";
import { transformTransportMessages } from "./transport-message-transform.js";
let configured = false;
/** Installs the agent and plugin ports only on paths that execute provider runtime. */
export function configureAiTransportRuntimeHost(): void {
if (configured) {
return;
}
const host = getAiTransportHost();
configureAiTransportHost({
...host,
plugin: {
...host.plugin,
resolveProviderStream: (params) =>
resolveProviderStreamFn({
...params,
config: params.config as OpenClawConfig | undefined,
context: {
...params.context,
config: params.context.config as OpenClawConfig | undefined,
model: params.context.model as ProviderRuntimeModel,
},
}),
resolveTransportTurnState: (params) =>
resolveProviderTransportTurnStateWithPlugin({
...params,
config: params.config as OpenClawConfig | undefined,
context: {
...params.context,
model: params.context.model as ProviderRuntimeModel | undefined,
},
}),
wrapSimpleCompletionStream: (params) =>
wrapProviderSimpleCompletionStreamFn({
...params,
config: params.config as OpenClawConfig | undefined,
context: {
...params.context,
config: params.context.config as OpenClawConfig | undefined,
model: params.context.model as ProviderRuntimeModel,
},
}),
createAnthropicVertexStream: createAnthropicVertexStreamFnForModel,
},
buildCopilotDynamicHeaders: (messages) =>
buildCopilotDynamicHeaders({ messages, hasImages: hasCopilotVisionInput(messages) }),
resolveProviderEndpointClass: (baseUrl) => resolveProviderEndpoint(baseUrl).endpointClass,
resolveProviderRequestCapabilities: (input) =>
resolveProviderRequestCapabilities(input) as AiProviderRequestCapabilities,
resolveProviderRequestHeaders: (input) =>
resolveProviderRequestPolicyConfig({
...input,
capability: "llm",
transport: "stream",
}).headers,
requiresManagedTransport: (model) => {
const request = getModelProviderRequestTransport(model);
return Boolean(request?.proxy || request?.tls || getModelProviderLocalService(model));
},
inheritManagedTransport: (source, target) =>
attachModelProviderLocalService(
attachModelProviderRequestTransport(target, getModelProviderRequestTransport(source)),
getModelProviderLocalService(source),
),
transformTransportMessages,
registerCustomApi: ensureCustomApiRegistered,
prepareGoogleSimpleCompletionModel,
});
configured = true;
}
configureAiTransportRuntimeHost();
+5 -4
View File
@@ -1,14 +1,15 @@
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared";
import "./ai-transport-runtime-host.js";
import {
applyAnthropicPayloadPolicyToParams,
resolveAnthropicPayloadPolicy,
} from "@openclaw/ai/transports";
/**
* Tests Anthropic payload policy mutation.
* Covers service tier, cache-control retention, prompt cache boundaries, and
* deprecated marker compatibility.
*/
import { describe, expect, it } from "vitest";
import {
applyAnthropicPayloadPolicyToParams,
resolveAnthropicPayloadPolicy,
} from "./anthropic-payload-policy.js";
type TestPayload = {
messages: Array<{ role: string; content: unknown }>;
@@ -5,9 +5,9 @@
*/
import http from "node:http";
import { streamAnthropic } from "@openclaw/ai/internal/anthropic";
import { createAnthropicMessagesTransportStreamFn } from "@openclaw/ai/transports";
import type { Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it } from "vitest";
import { createAnthropicMessagesTransportStreamFn } from "./anthropic-transport-stream.js";
import { isLiveTestEnabled } from "./live-test-helpers.js";
import { shouldSkipLiveProviderDrift } from "./live-test-provider-drift.js";
import { isLiveBillingDrift } from "./live-test-provider-drift.test-support.js";
+1 -1
View File
@@ -1,10 +1,10 @@
import type { StreamFn } from "@openclaw/llm-core";
/**
* Anthropic Vertex stream facade.
* Keeps Vertex-specific provider implementation in the bundled provider plugin
* while core imports a small stable factory.
*/
import { loadBundledPluginPublicSurfaceModuleSync } from "../plugin-sdk/facade-runtime.js";
import type { StreamFn } from "./runtime/index.js";
type AnthropicVertexStreamFacade = {
createAnthropicVertexStreamFn: (
-11
View File
@@ -10,8 +10,6 @@ import type { AnyAgentTool } from "./tools/common.js";
export const CODE_MODE_EXEC_TOOL_NAME = "exec";
/** Model-visible Code Mode wait tool name. */
export const CODE_MODE_WAIT_TOOL_NAME = "wait";
/** Direct tools whose structured results cannot cross the JSON-only guest bridge. */
const CODE_MODE_DIRECT_TOOL_NAMES: ReadonlySet<string> = new Set(["computer", "image"]);
/** Hook metadata kind for Code Mode exec tools. */
const CODE_MODE_EXEC_TOOL_KIND = "code_mode_exec";
@@ -48,15 +46,6 @@ export function isCodeModeControlTool(tool: AnyAgentTool): boolean {
return codeModeControlTools.has(tool);
}
/** Return whether a provider payload tool may remain model-visible in Code Mode. */
export function isCodeModeModelVisibleToolName(name: string): boolean {
return (
name === CODE_MODE_EXEC_TOOL_NAME ||
name === CODE_MODE_WAIT_TOOL_NAME ||
CODE_MODE_DIRECT_TOOL_NAMES.has(name)
);
}
function isCodeModeExecTool(tool: AnyAgentTool): boolean {
return isCodeModeControlTool(tool) && normalizeToolName(tool.name) === CODE_MODE_EXEC_TOOL_NAME;
}
+1 -1
View File
@@ -2,6 +2,7 @@
* Registers caller-supplied custom API stream functions with the LLM registry.
*/
import type { ApiRegistry } from "@openclaw/ai";
import type { StreamFn } from "@openclaw/llm-core";
import type {
Api,
AssistantMessageEventStreamContract,
@@ -9,7 +10,6 @@ import type {
StreamOptions,
} from "../llm/types.js";
import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js";
import type { StreamFn } from "./runtime/index.js";
import { buildStreamErrorAssistantMessage } from "./stream-message-shared.js";
const CUSTOM_API_SOURCE_PREFIX = "openclaw-custom-api:";
+1 -1
View File
@@ -1,9 +1,9 @@
import { createDeepSeekTextFilter } from "@openclaw/ai/transports";
/**
* Regression coverage for DeepSeek DSML streamed text filtering.
* Verifies complete, split, full-width, and unterminated DSML markup handling.
*/
import { describe, expect, it } from "vitest";
import { createDeepSeekTextFilter } from "./deepseek-text-filter.js";
function filteredText(chunks: readonly string[]) {
const filter = createDeepSeekTextFilter();
@@ -1,3 +1,5 @@
import { canonicalizeMaxTokensParam, resolveMaxTokensParam } from "@openclaw/ai/transports";
import { detectOpenAICompletionsCompat } from "@openclaw/ai/transports";
import {
type NativeWebSearchToolPolicyParams,
isNativeWebSearchAllowedByToolPolicy,
@@ -33,8 +35,6 @@ import {
} from "../../plugins/provider-hook-runtime.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import { resolveModelExtraParamSources } from "../model-extra-params.js";
import { canonicalizeMaxTokensParam, resolveMaxTokensParam } from "../model-max-tokens-params.js";
import { detectOpenAICompletionsCompat } from "../openai-completions-compat.js";
import { supportsGptParallelToolCallsPayload } from "../provider-api-families.js";
import { resolveProviderRequestPolicyConfig } from "../provider-request-config.js";
import type { AgentRuntimeTransport } from "../runtime-plan/types.js";
@@ -3,6 +3,7 @@
*/
import crypto from "node:crypto";
import { stripSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared";
import { mergeTransportHeaders, sanitizeTransportPayloadText } from "@openclaw/ai/transports";
import {
asDateTimestampMs,
isFutureDateTimestampMs,
@@ -25,7 +26,6 @@ import { buildGuardedModelFetch } from "../provider-transport-fetch.js";
import type { StreamFn } from "../runtime/index.js";
import { isSessionWriteLockAcquireError } from "../session-write-lock-error.js";
import { stableStringify } from "../stable-stringify.js";
import { mergeTransportHeaders, sanitizeTransportPayloadText } from "../transport-stream-shared.js";
import { log } from "./logger.js";
import { isGooglePromptCacheEligible, resolveCacheRetention } from "./prompt-cache-retention.js";
import { EmbeddedAttemptSessionTakeoverError } from "./run/attempt.session-lock.js";
@@ -127,6 +127,7 @@ const RETRY_GUARD_MODEL_APIS = new Set([
"openai-chatgpt-responses",
"azure-openai-responses",
"openclaw-openai-responses-transport",
"openclaw-openai-chatgpt-responses-transport",
"openclaw-azure-openai-responses-transport",
]);
// Allow one immediate continuation plus one follow-up continuation before
@@ -0,0 +1,42 @@
import { createLlmRuntime, getAiTransportHost } from "@openclaw/ai";
import type { Model } from "@openclaw/llm-core";
import { describe, expect, it } from "vitest";
describe("embedded stream transport host", () => {
it("installs runtime transport ports before resolving an embedded stream", async () => {
const inertResolver = getAiTransportHost().plugin.resolveProviderStream;
const { describeEmbeddedAgentStreamStrategy } = await import("./stream-resolution.js");
const model = {
api: "test-embedded-runtime-host-api",
provider: "test-embedded-runtime-host",
id: "test-embedded-runtime-host-model",
name: "Test Embedded Runtime Host Model",
baseUrl: "https://example.test",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1024,
maxTokens: 512,
} satisfies Model;
const resolver = getAiTransportHost().plugin.resolveProviderStream;
expect(resolver).not.toBe(inertResolver);
expect(
resolver({
provider: model.provider,
context: {
provider: model.provider,
modelId: model.id,
model,
},
}),
).toBeUndefined();
expect(
describeEmbeddedAgentStreamStrategy({
llmRuntime: createLlmRuntime(),
currentStreamFn: undefined,
model,
}),
).toBe("stream-simple");
});
});
@@ -1,6 +1,7 @@
import type { LlmRuntime } from "@openclaw/ai";
import { defaultLlmRuntime, getApiProvider } from "@openclaw/ai/internal/runtime";
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared";
import * as providerTransportStream from "@openclaw/ai/transports";
// Stream resolution tests cover how embedded runs choose provider, boundary,
// native Codex, or custom stream functions and pass auth/cache/signal options.
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
@@ -8,7 +9,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { bindStreamLlmRuntime } from "../../llm/model-runtime-binding.js";
import { streamSimple } from "../../llm/stream.js";
import { mintSecretSentinel } from "../../secrets/sentinel.js";
import * as providerTransportStream from "../provider-transport-stream.js";
import {
describeEmbeddedAgentStreamStrategy as describeEmbeddedAgentStreamStrategyImpl,
resolveEmbeddedAgentApiKey,
@@ -32,7 +32,7 @@ vi.mock("../../llm/stream.js", async (importOriginal) => {
// real transport stream; per-test overrideBoundaryAwareStreamFnOnce() injects
// a probe stream when a regression test needs to inspect the wrapped
// transport's options.
vi.mock("../provider-transport-stream.js", async (importOriginal) => {
vi.mock("@openclaw/ai/transports", async (importOriginal) => {
const actual = await importOriginal<typeof providerTransportStream>();
return {
...actual,
@@ -3,9 +3,10 @@
*/
import type { LlmRuntime } from "@openclaw/ai";
import { stripSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared";
import { createBoundaryAwareStreamFnForModel } from "@openclaw/ai/transports";
import { getStreamLlmRuntime } from "../../llm/model-runtime-binding.js";
import "../ai-transport-runtime-host.js";
import { createAnthropicVertexStreamFnForModel } from "../anthropic-vertex-stream.js";
import { createBoundaryAwareStreamFnForModel } from "../provider-transport-stream.js";
import type { StreamFn } from "../runtime/index.js";
import type { EmbeddedRunAttemptParams } from "./run/types.js";
@@ -547,6 +547,7 @@ describe("handleMessageUpdate text signatures", () => {
"openai-responses",
"openai-chatgpt-responses",
"openclaw-openai-responses-transport",
"openclaw-openai-chatgpt-responses-transport",
"openclaw-azure-openai-responses-transport",
])("streams %s commentary bytes exactly once across start, deltas, and end", async (api) => {
const onAgentEvent = vi.fn();
@@ -70,6 +70,7 @@ const RESPONSES_API_IDS = new Set([
"openai-chatgpt-responses",
"azure-openai-responses",
"openclaw-openai-responses-transport",
"openclaw-openai-chatgpt-responses-transport",
"openclaw-azure-openai-responses-transport",
]);
@@ -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,14 +6,14 @@ 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 type { StreamFn } from "@openclaw/llm-core";
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";
/** Custom API id for the Google simple-completion stream adapter. */
const GOOGLE_SIMPLE_COMPLETION_API: Api = "openclaw-google-generative-ai-simple";
+13 -10
View File
@@ -1,34 +1,37 @@
import { emitModelTransportDebug } from "@openclaw/ai/transports";
import { describe, expect, it, vi } from "vitest";
import { emitModelTransportDebug } from "./model-transport-debug.js";
describe("emitModelTransportDebug", () => {
function createLogger() {
const info = vi.fn();
const debug = vi.fn();
return {
info: vi.fn(),
debug: vi.fn(),
} as unknown as Parameters<typeof emitModelTransportDebug>[0];
log: { info, debug } as unknown as Parameters<typeof emitModelTransportDebug>[0],
info,
debug,
};
}
it("emits model-fetch metadata at info level by default", () => {
const log = createLogger();
const { log, info, debug } = createLogger();
emitModelTransportDebug(
log,
"[model-fetch] response provider=openai api=chat model=gpt status=200 latencyMs=42",
);
expect(log.info).toHaveBeenCalledWith(
expect(info).toHaveBeenCalledWith(
"[model-fetch] response provider=openai api=chat model=gpt status=200 latencyMs=42",
);
expect(log.debug).not.toHaveBeenCalled();
expect(debug).not.toHaveBeenCalled();
});
it("keeps non-model-fetch transport diagnostics at debug level by default", () => {
const log = createLogger();
const { log, info, debug } = createLogger();
emitModelTransportDebug(log, "[model-sse] event type=response.output_text.delta");
expect(log.debug).toHaveBeenCalledWith("[model-sse] event type=response.output_text.delta");
expect(log.info).not.toHaveBeenCalled();
expect(debug).toHaveBeenCalledWith("[model-sse] event type=response.output_text.delta");
expect(info).not.toHaveBeenCalled();
});
});
+4 -4
View File
@@ -1,12 +1,12 @@
import {
formatModelTransportDebugBaseUrl,
formatModelTransportDebugUrl,
} from "@openclaw/ai/transports";
/**
* Regression coverage for model transport debug URL formatting.
* Ensures credentials, query strings, and fragments stay out of diagnostics.
*/
import { describe, expect, it } from "vitest";
import {
formatModelTransportDebugBaseUrl,
formatModelTransportDebugUrl,
} from "./model-transport-url.js";
import { testing as openAITesting } from "./openai-transport-stream.test-support.js";
describe("model transport diagnostic URLs", () => {
+1 -1
View File
@@ -1,6 +1,7 @@
// Live-sweeps discovered model profiles with optional provider/model filters and probes.
import { writeSync } from "node:fs";
import { defaultApiRegistry } from "@openclaw/ai/internal/runtime";
import { prepareModelForSimpleCompletion } from "@openclaw/ai/transports";
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { expectDefined } from "@openclaw/normalization-core";
import { type Api, completeSimple, type Model } from "openclaw/plugin-sdk/llm";
@@ -72,7 +73,6 @@ import {
import { shouldSuppressBuiltInModel } from "./model-suppression.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import type { StreamFn } from "./runtime/index.js";
import { prepareModelForSimpleCompletion } from "./simple-completion-transport.js";
const LIVE = isLiveTestEnabled();
const DIRECT_ENABLED = Boolean(process.env.OPENCLAW_LIVE_MODELS?.trim());
@@ -1,6 +1,6 @@
import { hasOpenAICompatibleConversationTurn } from "@openclaw/ai/transports";
// Verifies OpenAI-compatible payloads contain at least one sendable conversation turn.
import { describe, expect, it } from "vitest";
import { hasOpenAICompatibleConversationTurn } from "./openai-compatible-conversation-turn.js";
describe("hasOpenAICompatibleConversationTurn", () => {
it("rejects missing, system-only, and tool-only payloads", () => {
@@ -1,6 +1,6 @@
import type { detectOpenAICompletionsCompat } from "./openai-completions-compat.js";
import type { detectOpenAICompletionsCompat } from "@openclaw/ai/transports";
import type { ProviderEndpointClass } from "./provider-attribution.js";
import "./openai-completions-compat.js";
import "@openclaw/ai/transports";
type OpenAICompletionsCompatDefaultsInput = {
provider?: string;
+1 -1
View File
@@ -1,6 +1,6 @@
import { detectOpenAICompletionsCompat } from "@openclaw/ai/transports";
// Verifies OpenAI-compatible endpoint defaults for streaming usage and reasoning payloads.
import { describe, expect, it } from "vitest";
import { detectOpenAICompletionsCompat } from "./openai-completions-compat.js";
import { resolveOpenAICompletionsCompatDefaults } from "./openai-completions-compat.test-support.js";
describe("resolveOpenAICompletionsCompatDefaults", () => {
@@ -1,13 +1,13 @@
import {
applyOpenAIResponsesPayloadPolicy,
resolveOpenAIResponsesPayloadPolicy,
} from "@openclaw/ai/transports";
/**
* Regression coverage for OpenAI Responses payload policy.
* Verifies store, prompt-cache, compaction, service-tier, and reasoning mutations.
*/
import type { Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it } from "vitest";
import {
applyOpenAIResponsesPayloadPolicy,
resolveOpenAIResponsesPayloadPolicy,
} from "./openai-responses-payload-policy.js";
describe("openai responses payload policy", () => {
it("forces store for native OpenAI responses payloads but keeps disable mode for transport defaults", () => {
@@ -236,7 +236,7 @@ describe("openai responses payload policy", () => {
it("emits store false for aliased native OpenAI Codex responses disable mode", () => {
const policy = resolveOpenAIResponsesPayloadPolicy(
{
api: "openclaw-openai-responses-transport",
api: "openclaw-openai-chatgpt-responses-transport",
provider: "openai",
baseUrl: "https://chatgpt.com/backend-api/codex",
},
@@ -1,9 +1,9 @@
import { resolveReplayableResponsesMessageId } from "@openclaw/ai/transports";
// Verifies OpenAI Responses replay preserves reasoning and response item ids.
import type { AssistantMessage, Model, ToolResultMessage } from "openclaw/plugin-sdk/llm";
import { stream } from "openclaw/plugin-sdk/llm";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { resolveReplayableResponsesMessageId } from "./openai-responses-replay.js";
function buildModel(): Model<"openai-responses"> {
return {
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import {
assertCodeModeResponsesToolSurface,
enforceCodeModeResponsesToolSurface,
} from "./openai-transport-params.js";
} from "@openclaw/ai/transports";
import { describe, expect, it } from "vitest";
describe("OpenAI Code Mode direct tools", () => {
it("keeps the native image loader model-visible", () => {
+12 -10
View File
@@ -1,3 +1,10 @@
import {
buildTransportAwareSimpleStreamFn,
createBoundaryAwareStreamFnForModel,
createOpenClawTransportStreamFnForModel,
prepareTransportAwareSimpleModel,
resolveTransportAwareSimpleApi,
} from "@openclaw/ai/transports";
import type { ChatCompletionChunk } from "openai/resources/chat/completions.js";
import type { Api, Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it, vi } from "vitest";
@@ -17,13 +24,6 @@ import {
} from "./openai-transport-stream.test-harness.js";
import { testing } from "./openai-transport-stream.test-support.js";
import { attachModelProviderRequestTransport } from "./provider-request-config.js";
import {
buildTransportAwareSimpleStreamFn,
createBoundaryAwareStreamFnForModel,
createOpenClawTransportStreamFnForModel,
prepareTransportAwareSimpleModel,
resolveTransportAwareSimpleApi,
} from "./provider-transport-stream.js";
describe("openai transport stream", () => {
it("keeps bounded redacted diagnostics UTF-16 well-formed", () => {
@@ -1176,7 +1176,7 @@ describe("openai transport stream", () => {
});
const transportAliasModel = {
...codexModel,
api: "openclaw-openai-responses-transport" as Api,
api: "openclaw-openai-chatgpt-responses-transport" as Api,
} satisfies Model;
const nonNativeChatGPTModel = makeResponsesModel({
...codexModel,
@@ -1352,9 +1352,11 @@ describe("openai transport stream", () => {
const prepared = prepareTransportAwareSimpleModel(model);
expect(resolveTransportAwareSimpleApi(model.api)).toBe("openclaw-openai-responses-transport");
expect(resolveTransportAwareSimpleApi(model.api)).toBe(
"openclaw-openai-chatgpt-responses-transport",
);
expectRecordFields(prepared, {
api: "openclaw-openai-responses-transport",
api: "openclaw-openai-chatgpt-responses-transport",
provider: "openai",
id: "codex-mini-latest",
});
@@ -1,12 +1,10 @@
import { createServer } from "node:http";
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared";
import { createOpenAICompletionsTransportStreamFn } from "@openclaw/ai/transports";
import type { ChatCompletionChunk } from "openai/resources/chat/completions.js";
import type { Api, Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it } from "vitest";
import {
buildOpenAICompletionsParams,
createOpenAICompletionsTransportStreamFn,
} from "./openai-transport-stream.js";
import { buildOpenAICompletionsParams } from "./openai-transport-stream.js";
import {
buildOpenAIResponsesParams,
type CapturedStreamEvent,
@@ -1044,7 +1042,7 @@ describe("openai transport stream", () => {
{
id: "gpt-5.5",
name: "GPT-5.5",
api: "openclaw-openai-responses-transport" as Api,
api: "openclaw-openai-chatgpt-responses-transport" as Api,
provider: "openai",
baseUrl: "https://chatgpt.com/backend-api/codex",
reasoning: true,
@@ -1,7 +1,7 @@
import { createServer } from "node:http";
import { createOpenAICompletionsTransportStreamFn } from "@openclaw/ai/transports";
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it } from "vitest";
import { createOpenAICompletionsTransportStreamFn } from "./openai-transport-stream.js";
import {
type OpenAICompletionsOutput,
type CapturedStreamEvent,
@@ -1,11 +1,11 @@
import { createServer } from "node:http";
import { createOpenAICompletionsTransportStreamFn } from "@openclaw/ai/transports";
import type { Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it, vi } from "vitest";
import {
classifyAssistantFailoverReason,
formatUserFacingAssistantErrorText,
} from "./embedded-agent-helpers.js";
import { createOpenAICompletionsTransportStreamFn } from "./openai-transport-stream.js";
import {
parseTransportChunkUsage,
type CapturedStreamEvent,
@@ -1,5 +1,5 @@
import "./openai-completions-transport.js";
import "./openai-responses-transport.js";
import "./ai-transport-runtime-host.js";
import "@openclaw/ai/transports";
const completionsTesting = globalThis.openclawOpenAICompletionsTransportTestApi;
const responsesTesting = globalThis.openclawOpenAIResponsesTransportTestApi;
+4 -7
View File
@@ -1,3 +1,6 @@
import { buildOpenAICompletionsParams as buildOpenAICompletionsParamsImpl } from "@openclaw/ai/transports";
import "./ai-transport-runtime-host.js";
import type { OpenAICompletionsOptions, OpenAIModeModel } from "@openclaw/ai/transports";
/**
* Public OpenAI transport surface.
*
@@ -5,14 +8,8 @@
* established imports stable while sharing only transport-neutral primitives between them.
*/
import type { Context } from "../llm/types.js";
import { buildOpenAICompletionsParams as buildOpenAICompletionsParamsImpl } from "./openai-completions-transport.js";
import type { OpenAICompletionsOptions, OpenAIModeModel } from "./openai-transport-shared.js";
export { createOpenAICompletionsTransportStreamFn } from "./openai-completions-transport.js";
export {
createAzureOpenAIResponsesTransportStreamFn,
createOpenAIResponsesTransportStreamFn,
} from "./openai-responses-transport.js";
export { createOpenAIResponsesTransportStreamFn } from "@openclaw/ai/transports";
// Keep this SDK-exported declaration anchored to the long-lived facade while the
// completions implementation remains independently owned.
+2 -1
View File
@@ -4,6 +4,8 @@
* model API once a concrete stream implementation exists.
*/
import type { ApiRegistry } from "@openclaw/ai";
import "./ai-transport-runtime-host.js";
import { createTransportAwareStreamFnForModel } from "@openclaw/ai/transports";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getModelLlmRuntime } from "../llm/model-runtime-binding.js";
import type { Api, Model } from "../llm/types.js";
@@ -14,7 +16,6 @@ import {
unwrapModelHeaderSentinelsForProviderEgress,
unwrapSecretSentinelsForProviderEgress,
} from "./provider-secret-egress.js";
import { createTransportAwareStreamFnForModel } from "./provider-transport-stream.js";
import type { StreamFn } from "./runtime/index.js";
/** Resolves and registers the stream function for a provider-backed model. */
+4 -4
View File
@@ -356,7 +356,7 @@ describe("buildGuardedModelFetch", () => {
const model = {
id: "gpt-5.5",
provider: "openai",
api: "openclaw-openai-responses-transport",
api: "openclaw-openai-chatgpt-responses-transport",
baseUrl: "https://chatgpt.com/backend-api/codex",
} as unknown as Model<"openai-responses">;
@@ -386,7 +386,7 @@ describe("buildGuardedModelFetch", () => {
const model = {
id: "gpt-5.5",
provider: "openai",
api: "openclaw-openai-responses-transport",
api: "openclaw-openai-chatgpt-responses-transport",
baseUrl: "https://chatgpt.com/backend-api/codex",
} as unknown as Model<"openai-responses">;
@@ -426,7 +426,7 @@ describe("buildGuardedModelFetch", () => {
const model = {
id: "gpt-5.5",
provider: "openai",
api: "openclaw-openai-responses-transport",
api: "openclaw-openai-chatgpt-responses-transport",
baseUrl: "https://chatgpt.com/backend-api/codex",
} as unknown as Model<"openai-responses">;
@@ -455,7 +455,7 @@ describe("buildGuardedModelFetch", () => {
const model = {
id: "gpt-5.5",
provider: "openai",
api: "openclaw-openai-responses-transport",
api: "openclaw-openai-chatgpt-responses-transport",
baseUrl: "https://chatgpt.com/backend-api/codex",
} as unknown as Model<"openai-responses">;
+3 -3
View File
@@ -4,6 +4,8 @@
* Applies request timeouts, proxy/TLS overrides, SSRF policy, local-service leases, retry hints, and SSE normalization.
*/
import { parseRetryAfterHttpDateMs } from "@openclaw/ai/internal/retry-after";
import { emitModelTransportDebug } from "@openclaw/ai/transports";
import { formatModelTransportDebugUrl } from "@openclaw/ai/transports";
import {
isCloudMetadataIpAddress,
isLinkLocalIpAddress,
@@ -36,8 +38,6 @@ import {
SECRET_SENTINEL_PATTERN,
swapSecretSentinelsInText,
} from "../secrets/sentinel.js";
import { emitModelTransportDebug } from "./model-transport-debug.js";
import { formatModelTransportDebugUrl } from "./model-transport-url.js";
import { ProviderHttpError, readResponseTextLimited } from "./provider-http-errors.js";
import {
ensureModelProviderLocalService,
@@ -461,7 +461,7 @@ async function requestBodyHasStreamTrue(
}
}
export function parseRetryAfterSeconds(headers: Headers): number | undefined {
function parseRetryAfterSeconds(headers: Headers): number | undefined {
const retryAfterMs = headers.get("retry-after-ms");
if (retryAfterMs) {
const trimmedRetryAfterMs = retryAfterMs.trim();
@@ -1,6 +1,6 @@
import { sanitizeResponsesImagePayload } from "@openclaw/ai/transports";
// Verifies Responses image payloads are canonicalized or replaced before send.
import { describe, expect, it } from "vitest";
import { sanitizeResponsesImagePayload } from "./responses-image-payload-sanitizer.js";
const PNG_1X1 =
// Valid JPEG-labeled data is sniffed as PNG and normalized to the real MIME type.
+1 -1
View File
@@ -1,3 +1,4 @@
import type { AgentMessage } from "@openclaw/agent-core";
/**
* Transcript repair helpers for tool-call replay.
*
@@ -9,7 +10,6 @@ import {
normalizeOptionalString,
readStringValue,
} from "@openclaw/normalization-core/string-coerce";
import type { AgentMessage } from "./runtime/index.js";
import { isThinkingLikeBlock } from "./thinking-block.js";
import {
extractToolCallsFromAssistant,
@@ -5,7 +5,7 @@ import {
type LlmRuntime,
} from "@openclaw/ai";
import { registerBuiltInApiProviders } from "@openclaw/ai/providers";
import "../../llm/ai-transport-host.js";
import "../ai-transport-runtime-host.js";
import { bindStreamLlmRuntime } from "../../llm/model-runtime-binding.js";
type ModelRegistryRuntime = {
+2 -1
View File
@@ -59,7 +59,8 @@ vi.mock("../plugins/current-plugin-metadata-snapshot.js", async (importOriginal)
getCurrentPluginMetadataSnapshot: hoisted.getCurrentPluginMetadataSnapshotMock,
}));
vi.mock("./simple-completion-transport.js", () => ({
vi.mock("@openclaw/ai/transports", async (importOriginal) => ({
...(await importOriginal<typeof import("@openclaw/ai/transports")>()),
prepareModelForSimpleCompletion: hoisted.prepareModelForSimpleCompletionMock,
}));
+1 -1
View File
@@ -1,5 +1,6 @@
import { supportsOpenAIReasoningEffort } from "@openclaw/ai/internal/openai";
import { defaultApiRegistry } from "@openclaw/ai/internal/runtime";
import { prepareModelForSimpleCompletion } from "@openclaw/ai/transports";
import { resolveClaudeSonnet5ModelIdentity } from "@openclaw/llm-core";
/**
* Simple completion runtime preparation.
@@ -53,7 +54,6 @@ import { buildAgentRuntimeAuthPlan } from "./runtime-plan/auth.js";
import { materializePreparedRuntimeModel } from "./runtime-plan/materialize-model.js";
import { getModelRegistryRuntime } from "./sessions/model-registry-runtime.js";
import { resolveSimpleCompletionModelResolverWorkspace } from "./simple-completion-scope.js";
import { prepareModelForSimpleCompletion } from "./simple-completion-transport.js";
import { resolveUtilityModelRefForAgent } from "./utility-model.js";
type SimpleCompletionAuthStorage = {
-175
View File
@@ -1,175 +0,0 @@
import type { ApiRegistry } from "@openclaw/ai";
/**
* Simple completion transport preparation.
*
* Registers provider-specific stream functions and rewrites models that need OpenClaw-managed transport semantics.
*/
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { Api, Model } from "../llm/types.js";
import { wrapProviderSimpleCompletionStreamFn } from "../plugins/provider-runtime.js";
import { createAnthropicVertexStreamFnForModel } from "./anthropic-vertex-stream.js";
import { ensureCustomApiRegistered } from "./custom-api-registry.js";
import { prepareGoogleSimpleCompletionModel } from "./google-simple-completion-stream.js";
import { registerProviderStreamForModel } from "./provider-stream.js";
import {
buildTransportAwareSimpleStreamFn,
createOpenClawTransportStreamFnForModel,
prepareTransportAwareSimpleModel,
resolveTransportAwareSimpleApi,
} from "./provider-transport-stream.js";
import type { StreamFn } from "./runtime/index.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?: OpenClawConfig,
): 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 = wrapProviderSimpleCompletionStreamFn({
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);
ensureCustomApiRegistered(registry, api, streamFn);
return { ...model, api };
}
function prepareCodexSimpleTransportModel<TApi extends Api>(
registry: ApiRegistry,
model: Model<TApi>,
cfg?: OpenClawConfig,
): 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;
}
ensureCustomApiRegistered(registry, api, streamFn);
return {
...transportModel,
api,
};
}
export function prepareModelForSimpleCompletion<TApi extends Api>(params: {
apiRegistry: ApiRegistry;
model: Model<TApi>;
cfg?: OpenClawConfig;
}): 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) {
ensureCustomApiRegistered(apiRegistry, transportAwareModel.api, streamFn);
return applyProviderSimpleCompletionWrapper(apiRegistry, transportAwareModel, cfg);
}
}
if (model.api === "google-generative-ai") {
return applyProviderSimpleCompletionWrapper(
apiRegistry,
prepareGoogleSimpleCompletionModel(apiRegistry, model),
cfg,
);
}
if (model.provider === "anthropic-vertex") {
const api = resolveAnthropicVertexSimpleApi(model.baseUrl);
ensureCustomApiRegistered(apiRegistry, api, createAnthropicVertexStreamFnForModel(model));
return applyProviderSimpleCompletionWrapper(apiRegistry, { ...model, api }, cfg);
}
return applyProviderSimpleCompletionWrapper(apiRegistry, model, cfg);
}
+1 -1
View File
@@ -1,10 +1,10 @@
import type { AgentMessage } from "@openclaw/agent-core";
/**
* Tool call id normalization and extraction helpers.
*
* Keeps provider-specific id formats replay-safe while preserving allowed native ids.
*/
import { sha256HexPrefix } from "../infra/crypto-digest.js";
import type { AgentMessage } from "./runtime/index.js";
import { isThinkingLikeBlock } from "./thinking-block.js";
import { isAllowedToolCallName, normalizeAllowedToolNames } from "./tool-call-shared.js";
+1 -1
View File
@@ -3,6 +3,7 @@
* This bypasses shared model runtime's content type system which does not have a "document" type.
*/
import { resolveAnthropicMessagesUrl } from "@openclaw/ai/transports";
import { readResponseBodySnippet } from "../../infra/http-error-body.js";
import {
postJsonRequest,
@@ -12,7 +13,6 @@ import {
import { normalizeProviderTransportWithPlugin } from "../../plugins/provider-runtime.js";
import { isRecord } from "../../utils.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import { resolveAnthropicMessagesUrl } from "../anthropic-transport-stream.js";
import type { ModelProviderRequestTransportOverrides } from "../provider-request-config.js";
import { unwrapSecretSentinelsForProviderEgress } from "../provider-secret-egress.js";
import { resolveProviderTransportSsrFPolicy } from "../provider-transport-fetch.js";
+3 -3
View File
@@ -205,6 +205,7 @@ const OPENAI_RESPONSES_APIS = new Set([
"azure-openai-responses",
"openai-chatgpt-responses",
"openclaw-openai-responses-transport",
"openclaw-openai-chatgpt-responses-transport",
"openclaw-azure-openai-responses-transport",
]);
const GOOGLE_REASONING_APIS = new Set([
@@ -239,9 +240,8 @@ function isAnthropicReasoningRoute(route: TranscriptAssistantRoute | undefined):
return typeof route?.api === "string" && ANTHROPIC_REASONING_APIS.has(route.api);
}
function isOpenAICompletionsRoute(route: TranscriptAssistantRoute | undefined): boolean {
return typeof route?.api === "string" && OPENAI_COMPLETIONS_APIS.has(route.api);
}
const isOpenAICompletionsRoute = (route?: TranscriptAssistantRoute) =>
OPENAI_COMPLETIONS_APIS.has(route?.api ?? "");
function isGoogleOpenAICompletionsRoute(route: TranscriptAssistantRoute | undefined): boolean {
return (
@@ -333,7 +333,10 @@ describe("transformTransportMessages synthetic tool-result policy", () => {
expect(toolResult.content).toEqual([{ type: "text", text: "aborted" }]);
});
it("preserves real OpenAI transport results and aborts missing parallel siblings", () => {
it.each([
"openclaw-openai-responses-transport",
"openclaw-openai-chatgpt-responses-transport",
] as const)("preserves real %s results and aborts missing parallel siblings", (api) => {
const messages: Context["messages"] = [
{
...assistantToolCall("call_keep"),
@@ -353,10 +356,7 @@ describe("transformTransportMessages synthetic tool-result policy", () => {
{ role: "user", content: "continue", timestamp: Date.now() },
];
const result = transformTransportMessages(
messages,
makeModel("openclaw-openai-responses-transport" as Api, "openai", "gpt-5.4"),
);
const result = transformTransportMessages(messages, makeModel(api as Api, "openai", "gpt-5.4"));
expect(result.map((msg) => msg.role)).toEqual([
"assistant",
+8 -6
View File
@@ -18,18 +18,20 @@ const SYNTHETIC_TOOL_RESULT_APIS = new Set<string>([
"openai-chatgpt-responses",
"azure-openai-responses",
"openclaw-openai-responses-transport",
"openclaw-openai-chatgpt-responses-transport",
"openclaw-azure-openai-responses-transport",
]);
// "aborted" is an OpenAI Responses-family convention from upstream Codex
// history normalization. Gemini/Anthropic transports use their own text while
// still needing synthetic results to satisfy provider turn-shape contracts;
// tool-replay-repair.live.test.ts exercises both paths against real models.
const CODEX_STYLE_ABORTED_OUTPUT_APIS = new Set<string>([
// "aborted" is the OpenAI Responses-family synthetic result convention,
// inherited from upstream Codex history normalization. It applies to public,
// Codex, Azure, and their OpenClaw transport aliases; Gemini/Anthropic use their
// own text. tool-replay-repair.live.test.ts exercises both paths against real models.
const OPENAI_RESPONSES_ABORTED_OUTPUT_APIS = new Set<string>([
"openai-responses",
"openai-chatgpt-responses",
"azure-openai-responses",
"openclaw-openai-responses-transport",
"openclaw-openai-chatgpt-responses-transport",
"openclaw-azure-openai-responses-transport",
]);
@@ -72,7 +74,7 @@ export function transformTransportMessages(
},
): Context["messages"] {
const allowSyntheticToolResults = defaultAllowSyntheticToolResults(model.api);
const syntheticToolResultText = CODEX_STYLE_ABORTED_OUTPUT_APIS.has(model.api)
const syntheticToolResultText = OPENAI_RESPONSES_ABORTED_OUTPUT_APIS.has(model.api)
? "aborted"
: "No result provided";
const toolCallIdMap = new Map<string, string>();
+4 -4
View File
@@ -1,6 +1,3 @@
// Transport stream shared tests cover payload sanitization, header merging, and
// final/error stream termination helpers used by provider transports.
import { describe, expect, it, vi } from "vitest";
import {
assignTransportErrorDetails,
failTransportStream,
@@ -8,7 +5,10 @@ import {
mergeTransportHeaders,
sanitizeNonEmptyTransportPayloadText,
sanitizeTransportPayloadText,
} from "./transport-stream-shared.js";
} from "@openclaw/ai/transports";
// Transport stream shared tests cover payload sanitization, header merging, and
// final/error stream termination helpers used by provider transports.
import { describe, expect, it, vi } from "vitest";
describe("transport stream shared helpers", () => {
it("sanitizes unpaired surrogate code units", () => {
@@ -1,3 +1,4 @@
import { normalizeCodexResponsesBaseUrlForOpenAISdk } from "@openclaw/ai/transports";
import type { TSchema } from "typebox";
import type {
WorkerInferenceContext,
@@ -44,7 +45,6 @@ import {
type PreparedSimpleCompletionModel,
} from "../../agents/simple-completion-runtime.js";
import { bindSimpleCompletionModelResolverWorkspace } from "../../agents/simple-completion-scope.js";
import { normalizeCodexResponsesBaseUrlForOpenAISdk } from "../../agents/simple-completion-transport.js";
import { normalizeUsage, hasNonzeroUsage } from "../../agents/usage.js";
import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
+7 -1
View File
@@ -3,7 +3,10 @@
// process-default stream facade.
import { configureAiTransportHost } from "@openclaw/ai";
import { resolveOpenAIStrictToolSetting } from "../agents/openai-strict-tool-setting.js";
import { buildGuardedModelFetch } from "../agents/provider-transport-fetch.js";
import {
buildGuardedModelFetch,
resolveModelRequestTimeoutMs,
} from "../agents/provider-transport-fetch.js";
import { redactSecrets, redactToolPayloadText } from "../logging/redact.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { swapSecretSentinelsInText } from "../secrets/sentinel.js";
@@ -34,6 +37,7 @@ configureAiTransportHost({
redactSecrets,
redactToolPayloadText,
resolveOpenAIStrictToolSetting,
resolveModelRequestTimeoutMs: (model) => resolveModelRequestTimeoutMs(model, undefined),
logDebug: (subsystem, build) => {
const log = transportLog(subsystem);
if (!log.isEnabled("debug", "any")) {
@@ -44,4 +48,6 @@ configureAiTransportHost({
log.debug(entry.message, entry.data);
}
},
logInfo: (subsystem, message, data) => transportLog(subsystem).info(message, data),
logWarn: (subsystem, message, data) => transportLog(subsystem).warn(message, data),
});
@@ -2,4 +2,4 @@
export {
applyAnthropicEphemeralCacheControlMarkers,
resolveAnthropicEphemeralCacheControl,
} from "../../../agents/anthropic-payload-policy.js";
} from "@openclaw/ai/transports";
@@ -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;
}
}
+2 -4
View File
@@ -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";
+9 -9
View File
@@ -2,6 +2,15 @@ import {
resolveOpenAIReasoningEffortForModel,
supportsOpenAIReasoningEffort,
} from "@openclaw/ai/internal/openai";
import { emitModelTransportDebug } from "@openclaw/ai/transports";
import {
flattenCompletionMessagesToStringContent,
stripCompletionMessagesToRoleContent,
} from "@openclaw/ai/transports";
import {
applyOpenAIResponsesPayloadPolicy,
resolveOpenAIResponsesPayloadPolicy,
} from "@openclaw/ai/transports";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
// OpenAI stream wrapper normalizes OpenAI-compatible streamed tool and text events.
import {
@@ -13,15 +22,6 @@ import {
patchCodexNativeWebSearchPayload,
resolveCodexNativeSearchActivation,
} from "../../../agents/codex-native-web-search-core.js";
import { emitModelTransportDebug } from "../../../agents/model-transport-debug.js";
import {
flattenCompletionMessagesToStringContent,
stripCompletionMessagesToRoleContent,
} from "../../../agents/openai-completions-string-content.js";
import {
applyOpenAIResponsesPayloadPolicy,
resolveOpenAIResponsesPayloadPolicy,
} from "../../../agents/openai-responses-payload-policy.js";
import {
resolveOpenAITextVerbosity,
type OpenAITextVerbosity,
@@ -1,5 +1,5 @@
// Stream payload utilities normalize provider stream payload fields for wrappers.
import type { StreamFn } from "../../../agents/runtime/index.js";
import type { StreamFn } from "@openclaw/llm-core";
/** Wraps a stream function and lets callers mutate outgoing provider payload objects. */
export function streamWithPayloadPatch(
+80
View File
@@ -0,0 +1,80 @@
import { createApiRegistry, createLlmRuntime, getAiTransportHost } from "@openclaw/ai";
import type {
AssistantMessage,
AssistantMessageEventStreamContract,
Context,
Model,
} from "@openclaw/llm-core";
import { describe, expect, it } from "vitest";
import { bindModelLlmRuntime } from "./model-runtime-binding.js";
import { completeSimple } from "./stream.js";
import { createAssistantMessageEventStream } from "./utils/event-stream.js";
describe("LLM completion transport host", () => {
it("installs runtime transport ports before a bare simple completion", async () => {
const registry = createApiRegistry();
const runtime = createLlmRuntime(registry);
const inertWrapper = getAiTransportHost().plugin.wrapSimpleCompletionStream;
const model = {
api: "test-runtime-host-api",
provider: "test-runtime-host",
id: "test-runtime-host-model",
name: "Test Runtime Host Model",
baseUrl: "https://example.test",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1024,
maxTokens: 512,
} satisfies Model;
const message = {
role: "assistant",
content: [{ type: "text", text: "configured" }],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
} satisfies AssistantMessage;
const providerStream = (
runtimeModel: Model,
context: Context,
): AssistantMessageEventStreamContract => {
const wrapper = getAiTransportHost().plugin.wrapSimpleCompletionStream;
expect(wrapper).not.toBe(inertWrapper);
expect(
wrapper({
provider: runtimeModel.provider,
context: {
provider: runtimeModel.provider,
modelId: runtimeModel.id,
model: runtimeModel,
streamFn: providerStream,
},
}),
).toBeUndefined();
expect(context.messages).toEqual([]);
const output = createAssistantMessageEventStream();
output.push({ type: "done", reason: "stop", message });
output.end();
return output;
};
registry.registerApiProvider({
api: model.api,
stream: providerStream,
streamSimple: providerStream,
});
await expect(
completeSimple(bindModelLlmRuntime(model, runtime), { messages: [] }),
).resolves.toEqual(message);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { createApiRegistry, createLlmRuntime, getAiTransportHost } from "@openclaw/ai";
import type {
AssistantMessage,
AssistantMessageEventStreamContract,
Context,
Model,
} from "@openclaw/llm-core";
import { describe, expect, it, vi } from "vitest";
import { bindModelLlmRuntime } from "./model-runtime-binding.js";
import { stream, streamSimple } from "./stream.js";
import { createAssistantMessageEventStream } from "./utils/event-stream.js";
describe("LLM synchronous stream transport host", () => {
it("defers provider streams until runtime transport ports are installed", async () => {
const registry = createApiRegistry();
const runtime = createLlmRuntime(registry);
const inertWrapper = getAiTransportHost().plugin.wrapSimpleCompletionStream;
const model = {
api: "test-sync-runtime-host-api",
provider: "test-sync-runtime-host",
id: "test-sync-runtime-host-model",
name: "Test Sync Runtime Host Model",
baseUrl: "https://example.test",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1024,
maxTokens: 512,
} satisfies Model;
const message = {
role: "assistant",
content: [{ type: "text", text: "configured" }],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
} satisfies AssistantMessage;
const providerStream = vi.fn(
(runtimeModel: Model, context: Context): AssistantMessageEventStreamContract => {
expect(getAiTransportHost().plugin.wrapSimpleCompletionStream).not.toBe(inertWrapper);
expect(context.messages).toEqual([]);
expect(runtimeModel.id).toBe(model.id);
const output = createAssistantMessageEventStream();
output.push({ type: "done", reason: "stop", message });
output.end();
return output;
},
);
registry.registerApiProvider({
api: model.api,
stream: providerStream,
streamSimple: providerStream,
});
const boundModel = bindModelLlmRuntime(model, runtime);
const outputs = [
stream(boundModel, { messages: [] }),
streamSimple(boundModel, { messages: [] }),
];
expect(providerStream).not.toHaveBeenCalled();
await expect(Promise.all(outputs.map((output) => output.result()))).resolves.toEqual([
message,
message,
]);
expect(providerStream).toHaveBeenCalledTimes(2);
});
});
+30 -7
View File
@@ -1,19 +1,37 @@
import { createApiRegistry, createLlmRuntime } from "@openclaw/ai";
import type { AssistantMessage, Model } from "@openclaw/llm-core";
import { describe, expect, it, vi } from "vitest";
import { bindModelLlmRuntime } from "./model-runtime-binding.js";
import { streamSimple } from "./stream.js";
import { createAssistantMessageEventStream } from "./utils/event-stream.js";
describe("LLM stream facade", () => {
it("routes a prepared model through its lifecycle runtime", () => {
it("routes a prepared model through its lifecycle runtime", async () => {
const registry = createApiRegistry();
const runtime = createLlmRuntime(registry);
const expected = createAssistantMessageEventStream();
const stream = vi.fn(() => expected);
const message = {
role: "assistant",
content: [{ type: "text", text: "complete" }],
api: "test-lifecycle-api",
provider: "test-provider",
model: "test-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
} satisfies AssistantMessage;
const providerStream = vi.fn(() => expected);
registry.registerApiProvider({
api: "test-lifecycle-api",
stream,
streamSimple: stream,
stream: providerStream,
streamSimple: providerStream,
});
const model = bindModelLlmRuntime(
{
@@ -27,11 +45,16 @@ describe("LLM stream facade", () => {
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1024,
maxTokens: 512,
},
} satisfies Model,
runtime,
);
expect(streamSimple(model, { messages: [] })).toBe(expected);
expect(stream).toHaveBeenCalledOnce();
const output = streamSimple(model, { messages: [] });
expect(providerStream).not.toHaveBeenCalled();
expected.push({ type: "done", reason: "stop", message });
expected.end();
await expect(output.result()).resolves.toEqual(message);
expect(providerStream).toHaveBeenCalledOnce();
});
});
+66 -6
View File
@@ -15,9 +15,63 @@ import type {
ProviderStreamOptions,
SimpleStreamOptions,
} from "./types.js";
import { createAssistantMessageEventStream } from "./utils/event-stream.js";
registerBuiltInApiProviders(defaultApiRegistry);
let transportRuntimeHostPromise: Promise<void> | undefined;
async function ensureTransportRuntimeHost(): Promise<void> {
// Async completion entry points install heavy provider ports before the runtime
// can invoke them, without adding their plugin graph to this eager facade.
transportRuntimeHostPromise ??= import("../agents/ai-transport-runtime-host.js").then(
({ configureAiTransportRuntimeHost }) => configureAiTransportRuntimeHost(),
);
await transportRuntimeHostPromise;
}
function createRuntimeHostErrorMessage(model: Model, error: unknown): AssistantMessage {
return {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "error",
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
}
function deferUntilTransportRuntimeHost(
model: Model,
start: () => AssistantMessageEventStreamContract,
): AssistantMessageEventStreamContract {
const output = createAssistantMessageEventStream();
void (async () => {
try {
await ensureTransportRuntimeHost();
for await (const event of start()) {
output.push(event);
}
} catch (error) {
const message = createRuntimeHostErrorMessage(model, error);
output.push({ type: "error", reason: "error", error: message });
} finally {
output.end();
}
})();
return output;
}
function resolveRuntime(model: Model) {
return getModelLlmRuntime(model) ?? defaultLlmRuntime;
}
@@ -27,15 +81,18 @@ export function stream<TApi extends Api>(
context: Context,
options?: ProviderStreamOptions,
): AssistantMessageEventStreamContract {
return resolveRuntime(model).stream(model, context, options);
return deferUntilTransportRuntimeHost(model, () =>
resolveRuntime(model).stream(model, context, options),
);
}
export function complete<TApi extends Api>(
export async function complete<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ProviderStreamOptions,
): Promise<AssistantMessage> {
return resolveRuntime(model).complete(model, context, options);
await ensureTransportRuntimeHost();
return await resolveRuntime(model).complete(model, context, options);
}
export function streamSimple<TApi extends Api>(
@@ -43,13 +100,16 @@ export function streamSimple<TApi extends Api>(
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStreamContract {
return resolveRuntime(model).streamSimple(model, context, options);
return deferUntilTransportRuntimeHost(model, () =>
resolveRuntime(model).streamSimple(model, context, options),
);
}
export function completeSimple<TApi extends Api>(
export async function completeSimple<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): Promise<AssistantMessage> {
return resolveRuntime(model).completeSimple(model, context, options);
await ensureTransportRuntimeHost();
return await resolveRuntime(model).completeSimple(model, context, options);
}
+1 -1
View File
@@ -5,4 +5,4 @@ export {
parseJsonObjectPreservingUnsafeIntegers,
parseJsonPreservingUnsafeIntegers,
quoteUnsafeIntegerLiterals,
} from "../agents/json-unsafe-integers.js";
} from "@openclaw/ai/transports";
+18 -312
View File
@@ -1,6 +1,6 @@
// Provider stream shared helpers implement reusable stream wrappers and payload policies.
import { resolveOpenAIReasoningEffortForModel } from "@openclaw/ai/internal/openai";
import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js";
import { resolveOpenAIReasoningEffortMap } from "@openclaw/ai/transports";
import {
createPromotedPlainTextToolCallBlock,
createPromotedPlainTextToolCallEvents,
@@ -11,9 +11,12 @@ import {
type PlainTextToolCallNameMatcher,
type PlainTextToolCallMessageNormalization,
} from "../../packages/tool-call-repair/src/index.js";
import { resolveOpenAIReasoningEffortMap } from "../agents/openai-reasoning-compat.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(
@@ -982,7 +689,7 @@ export function createGoogleThinkingStreamWrapper(
export {
applyAnthropicPayloadPolicyToParams,
resolveAnthropicPayloadPolicy,
} from "../agents/anthropic-payload-policy.js";
} from "@openclaw/ai/transports";
export { applyAnthropicEphemeralCacheControlMarkers } from "../llm/providers/stream-wrappers/anthropic-cache-control-payload.js";
export {
createMoonshotThinkingWrapper,
@@ -993,4 +700,3 @@ export {
createToolStreamWrapper,
createZaiToolStreamWrapper,
} from "../llm/providers/stream-wrappers/zai.js";
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+1 -1
View File
@@ -18,4 +18,4 @@ export {
mergeTransportHeaders,
sanitizeTransportPayloadText,
type WritableTransportStream,
} from "../agents/transport-stream-shared.js";
} from "@openclaw/ai/transports";

Some files were not shown because too many files have changed in this diff Show More