mirror of https://github.com/openclaw/openclaw.git
feat(providers): add llm transport adapter seam (#60264)
* feat(providers): add llm transport adapter seam * fix(providers): harden openai transport adapter * fix(providers): correct transport usage accounting
This commit is contained in:
parent
875c3813aa
commit
4798e125f4
|
|
@ -0,0 +1,120 @@
|
|||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTransportAwareSimpleStreamFn,
|
||||
isTransportAwareApiSupported,
|
||||
parseTransportChunkUsage,
|
||||
prepareTransportAwareSimpleModel,
|
||||
resolveAzureOpenAIApiVersion,
|
||||
resolveTransportAwareSimpleApi,
|
||||
sanitizeTransportPayloadText,
|
||||
} from "./openai-transport-stream.js";
|
||||
import { attachModelProviderRequestTransport } from "./provider-request-config.js";
|
||||
|
||||
describe("openai transport stream", () => {
|
||||
it("reports the supported transport-aware APIs", () => {
|
||||
expect(isTransportAwareApiSupported("openai-responses")).toBe(true);
|
||||
expect(isTransportAwareApiSupported("openai-completions")).toBe(true);
|
||||
expect(isTransportAwareApiSupported("azure-openai-responses")).toBe(true);
|
||||
expect(isTransportAwareApiSupported("anthropic-messages")).toBe(false);
|
||||
});
|
||||
|
||||
it("prepares a custom simple-completion api alias when transport overrides are attached", () => {
|
||||
const model = attachModelProviderRequestTransport(
|
||||
{
|
||||
id: "gpt-5",
|
||||
name: "GPT-5",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"openai-responses">,
|
||||
{
|
||||
proxy: {
|
||||
mode: "explicit-proxy",
|
||||
url: "http://proxy.internal:8443",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const prepared = prepareTransportAwareSimpleModel(model);
|
||||
|
||||
expect(resolveTransportAwareSimpleApi(model.api)).toBe("openclaw-openai-responses-transport");
|
||||
expect(prepared).toMatchObject({
|
||||
api: "openclaw-openai-responses-transport",
|
||||
provider: "openai",
|
||||
id: "gpt-5",
|
||||
});
|
||||
expect(buildTransportAwareSimpleStreamFn(model)).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("removes unpaired surrogate code units but preserves valid surrogate pairs", () => {
|
||||
const high = String.fromCharCode(0xd83d);
|
||||
const low = String.fromCharCode(0xdc00);
|
||||
|
||||
expect(sanitizeTransportPayloadText(`left${high}right`)).toBe("leftright");
|
||||
expect(sanitizeTransportPayloadText(`left${low}right`)).toBe("leftright");
|
||||
expect(sanitizeTransportPayloadText("emoji 🙈 ok")).toBe("emoji 🙈 ok");
|
||||
});
|
||||
|
||||
it("uses a valid Azure API version default when the environment is unset", () => {
|
||||
expect(resolveAzureOpenAIApiVersion({})).toBe("2024-12-01-preview");
|
||||
expect(resolveAzureOpenAIApiVersion({ AZURE_OPENAI_API_VERSION: "2025-01-01-preview" })).toBe(
|
||||
"2025-01-01-preview",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not double-count reasoning tokens and clamps uncached prompt usage at zero", () => {
|
||||
const model = {
|
||||
id: "gpt-5",
|
||||
name: "GPT-5",
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 2, cacheRead: 0.5, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"openai-completions">;
|
||||
|
||||
expect(
|
||||
parseTransportChunkUsage(
|
||||
{
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 30,
|
||||
prompt_tokens_details: { cached_tokens: 3 },
|
||||
completion_tokens_details: { reasoning_tokens: 7 },
|
||||
},
|
||||
model,
|
||||
),
|
||||
).toMatchObject({
|
||||
input: 7,
|
||||
output: 20,
|
||||
cacheRead: 3,
|
||||
totalTokens: 30,
|
||||
});
|
||||
|
||||
expect(
|
||||
parseTransportChunkUsage(
|
||||
{
|
||||
prompt_tokens: 2,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 4 },
|
||||
},
|
||||
model,
|
||||
),
|
||||
).toMatchObject({
|
||||
input: 0,
|
||||
output: 5,
|
||||
cacheRead: 4,
|
||||
totalTokens: 9,
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -232,22 +232,27 @@ describe("buildInlineProviderModels", () => {
|
|||
expect(result[0].headers).toEqual({ "X-Tenant": "acme" });
|
||||
});
|
||||
|
||||
it("rejects inline provider transport overrides that the llm model path cannot carry", () => {
|
||||
expect(() =>
|
||||
buildInlineProviderModels({
|
||||
proxy: {
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
api: "openai-completions",
|
||||
request: {
|
||||
proxy: {
|
||||
mode: "explicit-proxy",
|
||||
url: "http://proxy.internal:8443",
|
||||
},
|
||||
it("keeps inline provider transport overrides once the llm transport adapter is available", () => {
|
||||
const result = buildInlineProviderModels({
|
||||
proxy: {
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
api: "openai-completions",
|
||||
request: {
|
||||
proxy: {
|
||||
mode: "explicit-proxy",
|
||||
url: "http://proxy.internal:8443",
|
||||
},
|
||||
models: [makeModel("proxy-model")],
|
||||
},
|
||||
} as unknown as Parameters<typeof buildInlineProviderModels>[0]),
|
||||
).toThrow(/models\.providers\.\*\.request only supports headers and auth overrides/i);
|
||||
models: [makeModel("proxy-model")],
|
||||
},
|
||||
} as unknown as Parameters<typeof buildInlineProviderModels>[0]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
provider: "proxy",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits headers when neither provider nor model specifies them", () => {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from "../model-suppression.js";
|
||||
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
|
||||
import {
|
||||
attachModelProviderRequestTransport,
|
||||
resolveProviderRequestConfig,
|
||||
sanitizeConfiguredModelProviderRequest,
|
||||
} from "../provider-request-config.js";
|
||||
|
|
@ -355,18 +356,21 @@ function applyConfiguredProviderOverrides(params: {
|
|||
capability: "llm",
|
||||
transport: "stream",
|
||||
});
|
||||
return {
|
||||
...discoveredModel,
|
||||
api: requestConfig.api ?? "openai-responses",
|
||||
baseUrl: requestConfig.baseUrl ?? discoveredModel.baseUrl,
|
||||
reasoning: configuredModel?.reasoning ?? discoveredModel.reasoning,
|
||||
input: normalizedInput,
|
||||
cost: configuredModel?.cost ?? discoveredModel.cost,
|
||||
contextWindow: configuredModel?.contextWindow ?? discoveredModel.contextWindow,
|
||||
maxTokens: configuredModel?.maxTokens ?? discoveredModel.maxTokens,
|
||||
headers: requestConfig.headers,
|
||||
compat: configuredModel?.compat ?? discoveredModel.compat,
|
||||
};
|
||||
return attachModelProviderRequestTransport(
|
||||
{
|
||||
...discoveredModel,
|
||||
api: requestConfig.api ?? "openai-responses",
|
||||
baseUrl: requestConfig.baseUrl ?? discoveredModel.baseUrl,
|
||||
reasoning: configuredModel?.reasoning ?? discoveredModel.reasoning,
|
||||
input: normalizedInput,
|
||||
cost: configuredModel?.cost ?? discoveredModel.cost,
|
||||
contextWindow: configuredModel?.contextWindow ?? discoveredModel.contextWindow,
|
||||
maxTokens: configuredModel?.maxTokens ?? discoveredModel.maxTokens,
|
||||
headers: requestConfig.headers,
|
||||
compat: configuredModel?.compat ?? discoveredModel.compat,
|
||||
},
|
||||
providerRequest,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildInlineProviderModels(
|
||||
|
|
@ -401,13 +405,16 @@ export function buildInlineProviderModels(
|
|||
capability: "llm",
|
||||
transport: "stream",
|
||||
});
|
||||
return {
|
||||
...model,
|
||||
provider: trimmed,
|
||||
baseUrl: requestConfig.baseUrl,
|
||||
api: requestConfig.api ?? model.api,
|
||||
headers: requestConfig.headers,
|
||||
};
|
||||
return attachModelProviderRequestTransport(
|
||||
{
|
||||
...model,
|
||||
provider: trimmed,
|
||||
baseUrl: requestConfig.baseUrl ?? transport.baseUrl,
|
||||
api: requestConfig.api ?? model.api,
|
||||
headers: requestConfig.headers,
|
||||
},
|
||||
providerRequest,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -571,25 +578,28 @@ function resolveConfiguredFallbackModel(params: {
|
|||
provider,
|
||||
cfg,
|
||||
agentDir,
|
||||
model: {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: requestConfig.api ?? "openai-responses",
|
||||
provider,
|
||||
baseUrl: requestConfig.baseUrl,
|
||||
reasoning: configuredModel?.reasoning ?? false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow:
|
||||
configuredModel?.contextWindow ??
|
||||
providerConfig?.models?.[0]?.contextWindow ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
maxTokens:
|
||||
configuredModel?.maxTokens ??
|
||||
providerConfig?.models?.[0]?.maxTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
headers: requestConfig.headers,
|
||||
} as Model<Api>,
|
||||
model: attachModelProviderRequestTransport(
|
||||
{
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: requestConfig.api ?? "openai-responses",
|
||||
provider,
|
||||
baseUrl: requestConfig.baseUrl,
|
||||
reasoning: configuredModel?.reasoning ?? false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow:
|
||||
configuredModel?.contextWindow ??
|
||||
providerConfig?.models?.[0]?.contextWindow ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
maxTokens:
|
||||
configuredModel?.maxTokens ??
|
||||
providerConfig?.models?.[0]?.maxTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
headers: requestConfig.headers,
|
||||
} as Model<Api>,
|
||||
providerRequest,
|
||||
),
|
||||
runtimeHooks,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -310,8 +310,8 @@ describe("provider request config", () => {
|
|||
).toThrow(/request\.(headers\.X-Tenant|auth\.token|tls\.cert): unresolved SecretRef/i);
|
||||
});
|
||||
|
||||
it("rejects model-provider transport overrides that the llm path cannot carry", () => {
|
||||
expect(() =>
|
||||
it("keeps model-provider transport overrides once the llm path can carry them", () => {
|
||||
expect(
|
||||
sanitizeConfiguredModelProviderRequest({
|
||||
headers: {
|
||||
"X-Tenant": "acme",
|
||||
|
|
@ -321,7 +321,15 @@ describe("provider request config", () => {
|
|||
url: "http://proxy.internal:8443",
|
||||
},
|
||||
}),
|
||||
).toThrow(/models\.providers\.\*\.request only supports headers and auth overrides/i);
|
||||
).toEqual({
|
||||
headers: {
|
||||
"X-Tenant": "acme",
|
||||
},
|
||||
proxy: {
|
||||
mode: "explicit-proxy",
|
||||
url: "http://proxy.internal:8443",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("merges configured request overrides with later entries winning", () => {
|
||||
|
|
|
|||
|
|
@ -300,23 +300,10 @@ export function sanitizeConfiguredProviderRequest(
|
|||
};
|
||||
}
|
||||
|
||||
const MODEL_PROVIDER_REQUEST_TRANSPORT_MESSAGE =
|
||||
"models.providers.*.request only supports headers and auth overrides; proxy and TLS transport settings are not wired for model-provider requests";
|
||||
|
||||
export function sanitizeConfiguredModelProviderRequest(
|
||||
request: ConfiguredModelProviderRequest | ConfiguredProviderRequest | undefined,
|
||||
): ProviderRequestTransportOverrides | undefined {
|
||||
const sanitized = sanitizeConfiguredProviderRequest(request);
|
||||
if (!sanitized) {
|
||||
return undefined;
|
||||
}
|
||||
if (sanitized.proxy || sanitized.tls) {
|
||||
throw new Error(MODEL_PROVIDER_REQUEST_TRANSPORT_MESSAGE);
|
||||
}
|
||||
return {
|
||||
...(sanitized.headers ? { headers: sanitized.headers } : {}),
|
||||
...(sanitized.auth ? { auth: sanitized.auth } : {}),
|
||||
};
|
||||
return sanitizeConfiguredProviderRequest(request);
|
||||
}
|
||||
|
||||
export function mergeProviderRequestOverrides(
|
||||
|
|
@ -700,3 +687,29 @@ export function resolveProviderRequestHeaders(params: {
|
|||
request: params.request,
|
||||
}).headers;
|
||||
}
|
||||
|
||||
const MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL = Symbol.for(
|
||||
"openclaw.modelProviderRequestTransport",
|
||||
);
|
||||
|
||||
type ModelWithProviderRequestTransport = {
|
||||
[MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL]?: ProviderRequestTransportOverrides;
|
||||
};
|
||||
|
||||
export function attachModelProviderRequestTransport<TModel extends object>(
|
||||
model: TModel,
|
||||
request: ProviderRequestTransportOverrides | undefined,
|
||||
): TModel {
|
||||
if (!request) {
|
||||
return model;
|
||||
}
|
||||
const next = { ...model } as TModel & ModelWithProviderRequestTransport;
|
||||
next[MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL] = request;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getModelProviderRequestTransport(
|
||||
model: object,
|
||||
): ProviderRequestTransportOverrides | undefined {
|
||||
return (model as ModelWithProviderRequestTransport)[MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Api, Model } from "@mariozechner/pi-ai";
|
|||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveProviderStreamFn } from "../plugins/provider-runtime.js";
|
||||
import { ensureCustomApiRegistered } from "./custom-api-registry.js";
|
||||
import { createTransportAwareStreamFnForModel } from "./openai-transport-stream.js";
|
||||
|
||||
export function registerProviderStreamForModel<TApi extends Api>(params: {
|
||||
model: Model<TApi>;
|
||||
|
|
@ -11,20 +12,21 @@ export function registerProviderStreamForModel<TApi extends Api>(params: {
|
|||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): StreamFn | undefined {
|
||||
const streamFn = resolveProviderStreamFn({
|
||||
provider: params.model.provider,
|
||||
config: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
context: {
|
||||
config: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
const streamFn =
|
||||
resolveProviderStreamFn({
|
||||
provider: params.model.provider,
|
||||
modelId: params.model.id,
|
||||
model: params.model,
|
||||
},
|
||||
});
|
||||
config: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
context: {
|
||||
config: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
provider: params.model.provider,
|
||||
modelId: params.model.id,
|
||||
model: params.model,
|
||||
},
|
||||
}) ?? createTransportAwareStreamFnForModel(params.model);
|
||||
if (!streamFn) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import type { OpenClawConfig } from "../config/config.js";
|
|||
const createAnthropicVertexStreamFnForModel = vi.fn();
|
||||
const ensureCustomApiRegistered = vi.fn();
|
||||
const resolveProviderStreamFn = vi.fn();
|
||||
const buildTransportAwareSimpleStreamFn = vi.fn();
|
||||
const prepareTransportAwareSimpleModel = vi.fn();
|
||||
|
||||
vi.mock("./anthropic-vertex-stream.js", () => ({
|
||||
createAnthropicVertexStreamFnForModel,
|
||||
|
|
@ -14,6 +16,11 @@ vi.mock("./custom-api-registry.js", () => ({
|
|||
ensureCustomApiRegistered,
|
||||
}));
|
||||
|
||||
vi.mock("./openai-transport-stream.js", () => ({
|
||||
buildTransportAwareSimpleStreamFn,
|
||||
prepareTransportAwareSimpleModel,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/provider-runtime.js", () => ({
|
||||
resolveProviderStreamFn,
|
||||
}));
|
||||
|
|
@ -29,8 +36,12 @@ describe("prepareModelForSimpleCompletion", () => {
|
|||
createAnthropicVertexStreamFnForModel.mockReset();
|
||||
ensureCustomApiRegistered.mockReset();
|
||||
resolveProviderStreamFn.mockReset();
|
||||
buildTransportAwareSimpleStreamFn.mockReset();
|
||||
prepareTransportAwareSimpleModel.mockReset();
|
||||
createAnthropicVertexStreamFnForModel.mockReturnValue("vertex-stream");
|
||||
resolveProviderStreamFn.mockReturnValue("ollama-stream");
|
||||
buildTransportAwareSimpleStreamFn.mockReturnValue(undefined);
|
||||
prepareTransportAwareSimpleModel.mockImplementation((model) => model);
|
||||
});
|
||||
|
||||
it("registers the configured Ollama transport and keeps the original api", () => {
|
||||
|
|
@ -106,4 +117,39 @@ describe("prepareModelForSimpleCompletion", () => {
|
|||
api: "openclaw-anthropic-vertex-simple:https%3A%2F%2Fus-central1-aiplatform.googleapis.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a transport-aware custom api alias when llm request transport overrides are present", () => {
|
||||
const model: Model<"openai-responses"> = {
|
||||
id: "gpt-5",
|
||||
name: "GPT-5",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
|
||||
resolveProviderStreamFn.mockReturnValueOnce(undefined);
|
||||
buildTransportAwareSimpleStreamFn.mockReturnValueOnce("transport-stream");
|
||||
prepareTransportAwareSimpleModel.mockReturnValueOnce({
|
||||
...model,
|
||||
api: "openclaw-openai-responses-transport",
|
||||
});
|
||||
|
||||
const result = prepareModelForSimpleCompletion({ model });
|
||||
|
||||
expect(prepareTransportAwareSimpleModel).toHaveBeenCalledWith(model);
|
||||
expect(buildTransportAwareSimpleStreamFn).toHaveBeenCalledWith(model);
|
||||
expect(ensureCustomApiRegistered).toHaveBeenCalledWith(
|
||||
"openclaw-openai-responses-transport",
|
||||
"transport-stream",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
...model,
|
||||
api: "openclaw-openai-responses-transport",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { getApiProvider, type Api, type Model } from "@mariozechner/pi-ai";
|
|||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createAnthropicVertexStreamFnForModel } from "./anthropic-vertex-stream.js";
|
||||
import { ensureCustomApiRegistered } from "./custom-api-registry.js";
|
||||
import {
|
||||
buildTransportAwareSimpleStreamFn,
|
||||
prepareTransportAwareSimpleModel,
|
||||
} from "./openai-transport-stream.js";
|
||||
import { registerProviderStreamForModel } from "./provider-stream.js";
|
||||
|
||||
function resolveAnthropicVertexSimpleApi(baseUrl?: string): Api {
|
||||
|
|
@ -19,6 +23,15 @@ export function prepareModelForSimpleCompletion<TApi extends Api>(params: {
|
|||
return model;
|
||||
}
|
||||
|
||||
const transportAwareModel = prepareTransportAwareSimpleModel(model);
|
||||
if (transportAwareModel !== model) {
|
||||
const streamFn = buildTransportAwareSimpleStreamFn(model);
|
||||
if (streamFn) {
|
||||
ensureCustomApiRegistered(transportAwareModel.api, streamFn);
|
||||
return transportAwareModel;
|
||||
}
|
||||
}
|
||||
|
||||
if (model.provider === "anthropic-vertex") {
|
||||
const api = resolveAnthropicVertexSimpleApi(model.baseUrl);
|
||||
ensureCustomApiRegistered(api, createAnthropicVertexStreamFnForModel(model));
|
||||
|
|
|
|||
Loading…
Reference in New Issue