mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
committed by
GitHub
parent
f1205f5f0b
commit
b4e27f8b3d
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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";
|
||||
+2
-2
@@ -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";
|
||||
+87
-13
@@ -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);
|
||||
});
|
||||
+38
-39
@@ -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({
|
||||
@@ -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
-3
@@ -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;
|
||||
|
||||
+10
-4
@@ -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,
|
||||
+23
-23
@@ -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;
|
||||
+7
-4
@@ -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,
|
||||
+7
-7
@@ -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 {
|
||||
+28
-31
@@ -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,
|
||||
+33
-35
@@ -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 };
|
||||
+20
-6
@@ -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"> & {
|
||||
+74
-5
@@ -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", {
|
||||
+16
-17
@@ -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(
|
||||
+82
-38
@@ -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);
|
||||
}
|
||||
+9
-6
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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,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: (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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,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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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">;
|
||||
|
||||
|
||||
@@ -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,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 = {
|
||||
|
||||
@@ -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,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 = {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -5,4 +5,4 @@ export {
|
||||
parseJsonObjectPreservingUnsafeIntegers,
|
||||
parseJsonPreservingUnsafeIntegers,
|
||||
quoteUnsafeIntegerLiterals,
|
||||
} from "../agents/json-unsafe-integers.js";
|
||||
} from "@openclaw/ai/transports";
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user