mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
This reverts commit f5e9622fc9.
This commit is contained in:
@@ -716,8 +716,6 @@ catalog, API-key auth, and dynamic model resolution.
|
||||
| `validateReplayTurns` | Strict replay-turn validation before the embedded runner |
|
||||
| `onModelSelected` | Post-selection callback (e.g. telemetry) |
|
||||
|
||||
Custom `createStreamFn` transports must report provider acceptance before exposing the first stream event. Import the lifecycle helpers from `openclaw/plugin-sdk/provider-lifecycle`. Use `notifyProviderHttpResponse` when the transport owns a real `Response`, or `notifyProviderHttpMetadata` when an SDK exposes the real status and headers without the response body. Both helpers also run the compatibility `onResponse` callback. Use `notifyProviderStreamOpened` when an SDK returns an open stream but hides HTTP metadata; it never invents status or headers. Stream wrappers must forward `onProviderAccepted` and `onResponse` unchanged.
|
||||
|
||||
Runtime fallback notes:
|
||||
|
||||
- `normalizeConfig` resolves one owning plugin per provider id (bundled providers first, then the matched runtime plugin) and calls only that hook - there is no scan across other providers. Google's own `normalizeConfig` hook is what normalizes `google` / `google-vertex` / `google-antigravity` config entries; it is not a separate core fallback.
|
||||
|
||||
@@ -149,7 +149,6 @@ are private-local.
|
||||
| `plugin-sdk/provider-catalog-runtime` | Provider catalog augmentation runtime hook and plugin-provider registry seams for contract tests |
|
||||
| `plugin-sdk/provider-catalog-shared` | Private-local after July 2026; `findCatalogTemplate`, `buildSingleProviderApiKeyCatalog`, `buildManifestModelProviderConfig`, `supportsNativeStreamingUsageCompat`, `applyProviderNativeStreamingUsageCompat` |
|
||||
| `plugin-sdk/provider-http` | Private-local after July 2026; Generic provider HTTP/endpoint capability helpers, provider HTTP errors, and audio transcription multipart form helpers |
|
||||
| `plugin-sdk/provider-lifecycle` | Supported provider request-acceptance types and helpers for real HTTP metadata and metadata-free SDK or WebSocket streams |
|
||||
| `plugin-sdk/provider-binary-stream` | Direct-reader bounded binary streams with fitting-prefix delivery and explicit overflow/release errors |
|
||||
| `plugin-sdk/provider-web-fetch-contract` | Private-local after July 2026; Narrow web-fetch config/selection contract helpers such as `enablePluginInConfig` and `WebFetchProviderPlugin` |
|
||||
| `plugin-sdk/provider-web-fetch` | Private-local after July 2026; Web-fetch provider registration/cache helpers |
|
||||
|
||||
@@ -60,13 +60,9 @@ describe("createMantleAnthropicStreamFn", () => {
|
||||
const context = { messages: [] };
|
||||
const deps = createTestDeps();
|
||||
deps.stream.mockReturnValue(stream as never);
|
||||
const onProviderAccepted = vi.fn();
|
||||
const onResponse = vi.fn();
|
||||
|
||||
const result = createMantleAnthropicStreamFn(deps)(model, context, {
|
||||
apiKey: "bedrock-bearer-token",
|
||||
onProviderAccepted,
|
||||
onResponse,
|
||||
headers: {
|
||||
"X-Caller": "caller-header",
|
||||
},
|
||||
@@ -91,8 +87,6 @@ describe("createMantleAnthropicStreamFn", () => {
|
||||
"bedrock-bearer-token",
|
||||
);
|
||||
expect(streamOptions.thinkingEnabled).toBe(false);
|
||||
expect(streamOptions.onProviderAccepted).toBe(onProviderAccepted);
|
||||
expect(streamOptions.onResponse).toBe(onResponse);
|
||||
});
|
||||
|
||||
it("omits unsupported Opus 4.7 sampling and reasoning overrides", () => {
|
||||
|
||||
@@ -136,8 +136,6 @@ function buildMantleAnthropicBaseOptions(
|
||||
cacheRetention: options?.cacheRetention,
|
||||
sessionId: options?.sessionId,
|
||||
onPayload: options?.onPayload,
|
||||
onProviderAccepted: options?.onProviderAccepted,
|
||||
onResponse: options?.onResponse,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
metadata: options?.metadata,
|
||||
};
|
||||
|
||||
@@ -119,17 +119,12 @@ describe("Bedrock stream client lifecycle", () => {
|
||||
yield { messageStop: { stopReason: BedrockStopReason.END_TURN } };
|
||||
}
|
||||
const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({
|
||||
$metadata: { httpStatusCode: 200, requestId: "bedrock-request-1" },
|
||||
$metadata: { httpStatusCode: 200 },
|
||||
stream: successfulStream(),
|
||||
} as never);
|
||||
const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy");
|
||||
const onProviderAccepted = vi.fn();
|
||||
const onResponse = vi.fn();
|
||||
|
||||
const resultPromise = streamBedrockForTest(bedrockModel({}), context, {
|
||||
onProviderAccepted,
|
||||
onResponse,
|
||||
}).result();
|
||||
const resultPromise = streamBedrockForTest(bedrockModel({}), context).result();
|
||||
await streamBlocked;
|
||||
expect(destroy).not.toHaveBeenCalled();
|
||||
|
||||
@@ -137,18 +132,6 @@ describe("Bedrock stream client lifecycle", () => {
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(onProviderAccepted).toHaveBeenCalledWith(
|
||||
{
|
||||
kind: "http_response",
|
||||
status: 200,
|
||||
headers: { "x-amzn-requestid": "bedrock-request-1" },
|
||||
},
|
||||
expect.objectContaining({ provider: "amazon-bedrock" }),
|
||||
);
|
||||
expect(onResponse).toHaveBeenCalledWith(
|
||||
{ status: 200, headers: { "x-amzn-requestid": "bedrock-request-1" } },
|
||||
expect.objectContaining({ provider: "amazon-bedrock" }),
|
||||
);
|
||||
expectDestroyedClient(send, destroy);
|
||||
});
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
type ToolResultMessage,
|
||||
} from "openclaw/plugin-sdk/llm";
|
||||
import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { notifyProviderHttpMetadata } from "openclaw/plugin-sdk/provider-lifecycle";
|
||||
import {
|
||||
resolveClaudeFable5ModelIdentity,
|
||||
resolveClaudeModelIdentity,
|
||||
@@ -284,11 +283,10 @@ const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions> =
|
||||
if (response.$metadata.requestId) {
|
||||
responseHeaders["x-amzn-requestid"] = response.$metadata.requestId;
|
||||
}
|
||||
await notifyProviderHttpMetadata({
|
||||
options,
|
||||
response: { status: response.$metadata.httpStatusCode, headers: responseHeaders },
|
||||
await options?.onResponse?.(
|
||||
{ status: response.$metadata.httpStatusCode, headers: responseHeaders },
|
||||
model,
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
let sawMessageStop = false;
|
||||
|
||||
@@ -541,26 +541,6 @@ describe("createAnthropicVertexStreamFn", () => {
|
||||
expect(transportOptions).not.toHaveProperty("temperature");
|
||||
});
|
||||
|
||||
it("forwards provider acceptance hooks to the shared Anthropic transport", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
|
||||
const onProviderAccepted = vi.fn();
|
||||
const onResponse = vi.fn();
|
||||
|
||||
void streamFn(
|
||||
makeModel({ id: "claude-sonnet-4-6" }),
|
||||
{ messages: [] },
|
||||
{
|
||||
onProviderAccepted,
|
||||
onResponse,
|
||||
},
|
||||
);
|
||||
|
||||
const transportOptions = streamTransportOptions(streamAnthropicMock);
|
||||
expect(transportOptions.onProviderAccepted).toBe(onProviderAccepted);
|
||||
expect(transportOptions.onResponse).toBe(onResponse);
|
||||
});
|
||||
|
||||
it("keeps already-budgeted cache_control markers intact when forwarding payload hooks", async () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const onPayload = vi.fn(async (payload: unknown) => payload);
|
||||
|
||||
@@ -231,8 +231,6 @@ export function createAnthropicVertexStreamFn(
|
||||
// cache boundary and budgets all cache_control markers; re-applying the
|
||||
// payload policy here marked the uncached suffix and breached the 4-marker cap.
|
||||
onPayload: options?.onPayload,
|
||||
onProviderAccepted: options?.onProviderAccepted,
|
||||
onResponse: options?.onResponse,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
metadata: options?.metadata,
|
||||
};
|
||||
|
||||
@@ -630,58 +630,6 @@ describe("google transport stream", () => {
|
||||
expect(guardedFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports the real HTTP response before consuming Gemini SSE output", async () => {
|
||||
mockGoogleTextResponse();
|
||||
const onProviderAccepted = vi.fn();
|
||||
const onResponse = vi.fn();
|
||||
|
||||
const result = await runGeminiStreamResult({
|
||||
options: { onProviderAccepted, onResponse },
|
||||
});
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(onProviderAccepted).toHaveBeenCalledWith(
|
||||
{
|
||||
kind: "http_response",
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
},
|
||||
expect.objectContaining({ provider: "google" }),
|
||||
);
|
||||
expect(onResponse).toHaveBeenCalledWith(
|
||||
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
||||
expect.objectContaining({ provider: "google" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports rejected HTTP responses without marking them accepted", async () => {
|
||||
guardedFetchMock.mockResolvedValueOnce(
|
||||
new Response('{"error":{"message":"rate limited"}}', {
|
||||
status: 429,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-request-id": "req-rejected",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const onProviderAccepted = vi.fn();
|
||||
const onResponse = vi.fn();
|
||||
|
||||
const result = await runGeminiStreamResult({
|
||||
options: { onProviderAccepted, onResponse },
|
||||
});
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(onProviderAccepted).not.toHaveBeenCalled();
|
||||
expect(onResponse).toHaveBeenCalledWith(
|
||||
{
|
||||
status: 429,
|
||||
headers: expect.objectContaining({ "x-request-id": "req-rejected" }),
|
||||
},
|
||||
expect.objectContaining({ provider: "google" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the guarded fetch transport and parses Gemini SSE output", async () => {
|
||||
guardedFetchMock.mockResolvedValueOnce(
|
||||
buildSseResponse([
|
||||
@@ -1471,106 +1419,6 @@ describe("google transport stream", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("does not retry when a slow provider acceptance callback rejects", async () => {
|
||||
vi.stubEnv("OPENCLAW_GOOGLE_GEMINI_FIRST_RESPONSE_RETRY_MS", "10");
|
||||
let cancelCalled = false;
|
||||
guardedFetchMock.mockResolvedValueOnce(
|
||||
buildOpenRawSseResponse({
|
||||
sse: 'data: {"candidates":[{"finishReason":"STOP"}]}\n\n',
|
||||
onCancel: () => {
|
||||
cancelCalled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runGeminiStreamResult({
|
||||
model: buildGeminiModel({ id: "gemini-3.1-pro-preview" }),
|
||||
options: {
|
||||
reasoning: "high",
|
||||
onProviderAccepted: async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
throw new Error("acceptance callback failed");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
stopReason: "error",
|
||||
errorMessage: "acceptance callback failed",
|
||||
});
|
||||
expect(guardedFetchMock).toHaveBeenCalledOnce();
|
||||
expect(cancelCalled).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["onProviderAccepted", "onResponse"] as const)(
|
||||
"aborts a pending %s callback without retrying",
|
||||
async (hookName) => {
|
||||
vi.stubEnv("OPENCLAW_GOOGLE_GEMINI_FIRST_RESPONSE_RETRY_MS", "1000");
|
||||
const controller = new AbortController();
|
||||
const cancel = vi.fn();
|
||||
guardedFetchMock.mockResolvedValueOnce(
|
||||
buildOpenRawSseResponse({
|
||||
sse: 'data: {"candidates":[{"finishReason":"STOP"}]}\n\n',
|
||||
onCancel: cancel,
|
||||
}),
|
||||
);
|
||||
let markHookStarted!: () => void;
|
||||
const hookStarted = new Promise<void>((resolve) => {
|
||||
markHookStarted = resolve;
|
||||
});
|
||||
const hook = vi.fn(() => {
|
||||
markHookStarted();
|
||||
return new Promise<void>(() => {});
|
||||
});
|
||||
|
||||
const resultPromise = runGeminiStreamResult({
|
||||
model: buildGeminiModel({ id: "gemini-3.1-pro-preview" }),
|
||||
options: {
|
||||
reasoning: "high",
|
||||
signal: controller.signal,
|
||||
[hookName]: hook,
|
||||
},
|
||||
});
|
||||
await hookStarted;
|
||||
controller.abort(
|
||||
Object.assign(new Error("operator canceled the request"), {
|
||||
code: "OPERATOR_CANCELLED",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
stopReason: "aborted",
|
||||
errorCode: "OPERATOR_CANCELLED",
|
||||
errorMessage: "operator canceled the request",
|
||||
});
|
||||
expect(hook).toHaveBeenCalledOnce();
|
||||
expect(guardedFetchMock).toHaveBeenCalledOnce();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not count provider acceptance callback time against the retry deadline", async () => {
|
||||
vi.stubEnv("OPENCLAW_GOOGLE_GEMINI_FIRST_RESPONSE_RETRY_MS", "10");
|
||||
mockGoogleTextResponse("accepted");
|
||||
|
||||
const result = await runGeminiStreamResult({
|
||||
model: buildGeminiModel({ id: "gemini-3.1-pro-preview" }),
|
||||
options: {
|
||||
reasoning: "high",
|
||||
onProviderAccepted: async () => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.content).toEqual([{ type: "text", text: "accepted" }]);
|
||||
expect(guardedFetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps oversized-video shedding in the Gemini 3 retry payload", async () => {
|
||||
vi.stubEnv("OPENCLAW_GOOGLE_GEMINI_FIRST_RESPONSE_RETRY_MS", "10");
|
||||
guardedFetchMock
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
providerOperationRetryConfig,
|
||||
resolveProviderRequestHeaders,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { notifyProviderHttpResponse } from "openclaw/plugin-sdk/provider-lifecycle";
|
||||
import {
|
||||
buildGuardedModelFetch,
|
||||
coerceTransportToolCallArguments,
|
||||
@@ -1042,43 +1041,10 @@ function buildGoogleGemini3FirstResponseRetryParams(params: {
|
||||
function createChildSignal(parent: AbortSignal | undefined, timeoutMs: number) {
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
let remainingMs = timeoutMs;
|
||||
let deadlineStartedAt: number | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const abortFromParent = () => {
|
||||
controller.abort(parent?.reason);
|
||||
};
|
||||
const abortForTimeout = () => {
|
||||
timedOut = true;
|
||||
timeout = undefined;
|
||||
deadlineStartedAt = undefined;
|
||||
controller.abort(new Error("Google Gemini first response retry deadline reached"));
|
||||
};
|
||||
const startDeadline = () => {
|
||||
if (timeout || controller.signal.aborted || timeoutMs <= 0) {
|
||||
return;
|
||||
}
|
||||
if (remainingMs <= 0) {
|
||||
abortForTimeout();
|
||||
return;
|
||||
}
|
||||
deadlineStartedAt = Date.now();
|
||||
timeout = setTimeout(abortForTimeout, remainingMs);
|
||||
timeout.unref?.();
|
||||
};
|
||||
const clearDeadline = () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
deadlineStartedAt = undefined;
|
||||
};
|
||||
const pauseDeadline = () => {
|
||||
if (deadlineStartedAt !== undefined) {
|
||||
remainingMs = Math.max(0, remainingMs - (Date.now() - deadlineStartedAt));
|
||||
}
|
||||
clearDeadline();
|
||||
};
|
||||
if (parent) {
|
||||
if (parent.aborted) {
|
||||
abortFromParent();
|
||||
@@ -1086,12 +1052,22 @@ function createChildSignal(parent: AbortSignal | undefined, timeoutMs: number) {
|
||||
parent.addEventListener("abort", abortFromParent, { once: true });
|
||||
}
|
||||
}
|
||||
startDeadline();
|
||||
if (timeoutMs > 0) {
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort(new Error("Google Gemini first response retry deadline reached"));
|
||||
}, timeoutMs);
|
||||
timeout.unref?.();
|
||||
}
|
||||
const clearDeadline = () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
}
|
||||
};
|
||||
return {
|
||||
signal: controller.signal,
|
||||
timedOut: () => timedOut,
|
||||
pauseDeadline,
|
||||
resumeDeadline: startDeadline,
|
||||
clearDeadline,
|
||||
cleanup: () => {
|
||||
clearDeadline();
|
||||
@@ -1128,20 +1104,6 @@ type GoogleSseAttempt =
|
||||
}
|
||||
| { type: "timeout" };
|
||||
|
||||
async function notifyGoogleTransportHttpResponse(
|
||||
model: GoogleTransportModel,
|
||||
options: GoogleTransportOptions | undefined,
|
||||
response: Response,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await notifyProviderHttpResponse({
|
||||
options,
|
||||
response,
|
||||
model: canonicalGoogleModel(model),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function openGoogleSseAttempt(params: {
|
||||
guardedFetch: ReturnType<typeof buildGuardedModelFetch>;
|
||||
url: string;
|
||||
@@ -1151,64 +1113,44 @@ async function openGoogleSseAttempt(params: {
|
||||
parentSignal?: AbortSignal;
|
||||
firstResponseTimeoutMs: number;
|
||||
errorPrefix: string;
|
||||
model: GoogleTransportModel;
|
||||
options: GoogleTransportOptions | undefined;
|
||||
}): Promise<GoogleSseAttempt> {
|
||||
const attemptSignal =
|
||||
params.firstResponseTimeoutMs > 0
|
||||
? createChildSignal(params.parentSignal, params.firstResponseTimeoutMs)
|
||||
: undefined;
|
||||
const signal = attemptSignal?.signal ?? params.parentSignal;
|
||||
const handleTimedOperationError = (error: unknown): GoogleSseAttempt => {
|
||||
attemptSignal?.cleanup();
|
||||
if (attemptSignal?.timedOut() && !params.parentSignal?.aborted) {
|
||||
return { type: "timeout" };
|
||||
}
|
||||
throw error;
|
||||
};
|
||||
let response: Response;
|
||||
try {
|
||||
response = await params.guardedFetch(params.url, {
|
||||
const response = await params.guardedFetch(params.url, {
|
||||
method: "POST",
|
||||
headers: params.headers,
|
||||
body: serializeGoogleRequest(params.request, params.videoSlots),
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleTimedOperationError(error);
|
||||
}
|
||||
attemptSignal?.pauseDeadline();
|
||||
try {
|
||||
await notifyGoogleTransportHttpResponse(params.model, params.options, response, signal);
|
||||
} catch (error) {
|
||||
attemptSignal?.cleanup();
|
||||
throw error;
|
||||
}
|
||||
if (!response.ok) {
|
||||
attemptSignal?.cleanup();
|
||||
throw await createProviderHttpError(response, params.errorPrefix);
|
||||
}
|
||||
attemptSignal?.resumeDeadline();
|
||||
const chunks = parseGoogleSseChunks(response, signal);
|
||||
const iterator = chunks[Symbol.asyncIterator]();
|
||||
let first: IteratorResult<GoogleSseChunk>;
|
||||
try {
|
||||
first = await iterator.next();
|
||||
} catch (error) {
|
||||
return handleTimedOperationError(error);
|
||||
}
|
||||
attemptSignal?.clearDeadline();
|
||||
if (first.done) {
|
||||
if (!response.ok) {
|
||||
throw await createProviderHttpError(response, params.errorPrefix);
|
||||
}
|
||||
const chunks = parseGoogleSseChunks(response, signal);
|
||||
const iterator = chunks[Symbol.asyncIterator]();
|
||||
const first = await iterator.next();
|
||||
attemptSignal?.clearDeadline();
|
||||
if (first.done) {
|
||||
return {
|
||||
type: "ready",
|
||||
chunks: iteratorToAsyncGenerator(iterator, attemptSignal?.cleanup),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "ready",
|
||||
firstChunk: first.value,
|
||||
chunks: iteratorToAsyncGenerator(iterator, attemptSignal?.cleanup),
|
||||
};
|
||||
} catch (error) {
|
||||
attemptSignal?.cleanup();
|
||||
if (attemptSignal?.timedOut() && !params.parentSignal?.aborted) {
|
||||
return { type: "timeout" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
type: "ready",
|
||||
firstChunk: first.value,
|
||||
chunks: iteratorToAsyncGenerator(iterator, attemptSignal?.cleanup),
|
||||
};
|
||||
}
|
||||
|
||||
async function openGoogleSseChunks(params: {
|
||||
@@ -1232,12 +1174,6 @@ async function openGoogleSseChunks(params: {
|
||||
body: serializeGoogleRequest(params.request, params.videoSlots),
|
||||
signal: params.options?.signal,
|
||||
});
|
||||
await notifyGoogleTransportHttpResponse(
|
||||
params.model,
|
||||
params.options,
|
||||
response,
|
||||
params.options?.signal,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await createProviderHttpError(response, errorPrefix);
|
||||
}
|
||||
@@ -1255,12 +1191,6 @@ async function openGoogleSseChunks(params: {
|
||||
body: serializeGoogleRequest(params.request, params.videoSlots),
|
||||
signal: params.options?.signal,
|
||||
});
|
||||
await notifyGoogleTransportHttpResponse(
|
||||
params.model,
|
||||
params.options,
|
||||
response,
|
||||
params.options?.signal,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await createProviderHttpError(response, errorPrefix);
|
||||
}
|
||||
@@ -1279,8 +1209,6 @@ async function openGoogleSseChunks(params: {
|
||||
parentSignal: params.options?.signal,
|
||||
firstResponseTimeoutMs: retryMs,
|
||||
errorPrefix,
|
||||
model: params.model,
|
||||
options: params.options,
|
||||
});
|
||||
if (firstAttempt.type === "ready") {
|
||||
return firstAttempt;
|
||||
@@ -1301,8 +1229,6 @@ async function openGoogleSseChunks(params: {
|
||||
parentSignal: params.options?.signal,
|
||||
firstResponseTimeoutMs: 0,
|
||||
errorPrefix,
|
||||
model: params.model,
|
||||
options: params.options,
|
||||
});
|
||||
if (retryAttempt.type === "timeout") {
|
||||
throw new Error("Google Gemini first response retry timed out unexpectedly");
|
||||
|
||||
@@ -456,32 +456,6 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("reports the real HTTP response before consuming native Ollama output", async () => {
|
||||
await withSuccessfulOllamaFetch(async () => {
|
||||
const onProviderAccepted = vi.fn();
|
||||
const onResponse = vi.fn();
|
||||
const stream = await createOllamaTestStream({
|
||||
baseUrl: "http://ollama-host:11434",
|
||||
options: { onProviderAccepted, onResponse },
|
||||
});
|
||||
|
||||
await collectStreamEvents(stream);
|
||||
|
||||
expect(onProviderAccepted).toHaveBeenCalledWith(
|
||||
{
|
||||
kind: "http_response",
|
||||
status: 200,
|
||||
headers: { "content-type": "application/x-ndjson" },
|
||||
},
|
||||
expect.objectContaining({ provider: "custom-ollama" }),
|
||||
);
|
||||
expect(onResponse).toHaveBeenCalledWith(
|
||||
{ status: 200, headers: { "content-type": "application/x-ndjson" } },
|
||||
expect.objectContaining({ provider: "custom-ollama" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("passes resolved provider request timeouts to native Ollama chat fetches", async () => {
|
||||
await withMockNdjsonFetch(
|
||||
[
|
||||
|
||||
@@ -19,7 +19,6 @@ import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm";
|
||||
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import { notifyProviderHttpResponse } from "openclaw/plugin-sdk/provider-lifecycle";
|
||||
import { createPlainTextToolCallCompatWrapper } from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
@@ -76,6 +75,36 @@ function throwIfOllamaStreamAborted(signal?: AbortSignal): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function runOllamaResponseHook(params: {
|
||||
hook: (() => void | Promise<void>) | undefined;
|
||||
signal: AbortSignal | undefined;
|
||||
}): Promise<void> {
|
||||
const { hook, signal } = params;
|
||||
if (!hook) {
|
||||
return;
|
||||
}
|
||||
throwIfOllamaStreamAborted(signal);
|
||||
if (!signal) {
|
||||
await hook();
|
||||
return;
|
||||
}
|
||||
let onAbort: (() => void) | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.resolve().then(hook),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
onAbort = () => reject(new Error("Request was aborted"));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (onAbort) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
throwIfOllamaStreamAborted(signal);
|
||||
}
|
||||
|
||||
function createOllamaStreamCooperativeScheduler(
|
||||
signal?: AbortSignal,
|
||||
): OllamaStreamCooperativeScheduler {
|
||||
@@ -1026,7 +1055,26 @@ function createRawOllamaStreamFn(
|
||||
});
|
||||
|
||||
try {
|
||||
await notifyProviderHttpResponse({ options, response, model });
|
||||
const responseHook = options?.onResponse;
|
||||
try {
|
||||
await runOllamaResponseHook({
|
||||
hook: responseHook
|
||||
? () =>
|
||||
responseHook(
|
||||
{
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
},
|
||||
model,
|
||||
)
|
||||
: undefined,
|
||||
signal: options?.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
// A pending body cancel must not stall release or the terminal error.
|
||||
void response.body?.cancel().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorText = await readResponseTextLimited(
|
||||
response,
|
||||
|
||||
@@ -1319,10 +1319,6 @@
|
||||
"./plugin-sdk/provider-http": {
|
||||
"default": "./dist/plugin-sdk/provider-http.js"
|
||||
},
|
||||
"./plugin-sdk/provider-lifecycle": {
|
||||
"types": "./dist/plugin-sdk/provider-lifecycle.d.ts",
|
||||
"default": "./dist/plugin-sdk/provider-lifecycle.js"
|
||||
},
|
||||
"./plugin-sdk/provider-binary-stream": {
|
||||
"types": "./dist/plugin-sdk/provider-binary-stream.d.ts",
|
||||
"default": "./dist/plugin-sdk/provider-binary-stream.js"
|
||||
|
||||
@@ -118,8 +118,6 @@ export interface AgentOptions {
|
||||
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
/** Inspect the provider payload before it is sent. */
|
||||
onPayload?: SimpleStreamOptions["onPayload"];
|
||||
/** Observe when the provider accepts the request. */
|
||||
onProviderAccepted?: SimpleStreamOptions["onProviderAccepted"];
|
||||
/** Inspect the provider response after it returns. */
|
||||
onResponse?: SimpleStreamOptions["onResponse"];
|
||||
/** Hook that may short-circuit or alter a tool call before execution. */
|
||||
@@ -231,7 +229,6 @@ export class Agent {
|
||||
public streamFn: StreamFn;
|
||||
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
public onPayload?: SimpleStreamOptions["onPayload"];
|
||||
public onProviderAccepted?: SimpleStreamOptions["onProviderAccepted"];
|
||||
public onResponse?: SimpleStreamOptions["onResponse"];
|
||||
public beforeToolCall?: (
|
||||
context: BeforeToolCallContext,
|
||||
@@ -273,7 +270,6 @@ export class Agent {
|
||||
this.streamFn = resolveAgentCoreStreamFn(options.runtime, options.streamFn);
|
||||
this.getApiKey = options.getApiKey;
|
||||
this.onPayload = options.onPayload;
|
||||
this.onProviderAccepted = options.onProviderAccepted;
|
||||
this.onResponse = options.onResponse;
|
||||
this.beforeToolCall = options.beforeToolCall;
|
||||
this.resolveDeferredTool = options.resolveDeferredTool;
|
||||
@@ -525,7 +521,6 @@ export class Agent {
|
||||
),
|
||||
sessionId: this.sessionId,
|
||||
onPayload: this.onPayload,
|
||||
onProviderAccepted: this.onProviderAccepted,
|
||||
onResponse: this.onResponse,
|
||||
transport: this.transport,
|
||||
thinkingBudgets: this.thinkingBudgets,
|
||||
|
||||
@@ -29,10 +29,7 @@ import {
|
||||
type AnthropicCompactionBlock,
|
||||
} from "../transports/anthropic-compaction-replay.js";
|
||||
import { applyAnthropicCacheControlToMessages } from "../transports/anthropic-payload-policy.js";
|
||||
import {
|
||||
notifyProviderHttpResponse,
|
||||
transportAbortError,
|
||||
} from "../transports/transport-stream-shared.js";
|
||||
import { transportAbortError } from "../transports/transport-stream-shared.js";
|
||||
import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../transports/transport-utils.js";
|
||||
import type {
|
||||
AnthropicMessagesCompat,
|
||||
@@ -52,6 +49,7 @@ import type {
|
||||
} from "../types.js";
|
||||
import { createDeferredEventBuffer } from "../utils/deferred-event-buffer.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { headersToRecord } from "../utils/headers.js";
|
||||
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.js";
|
||||
import { notifyLlmRequestActivity } from "../utils/llm-request-activity.js";
|
||||
import { projectProviderError } from "../utils/provider-error.js";
|
||||
@@ -424,7 +422,10 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicComp
|
||||
const response = await client.messages
|
||||
.create({ ...params, stream: true }, sdkRequestOptions)
|
||||
.asResponse();
|
||||
await notifyProviderHttpResponse({ options: requestOptions, response, model });
|
||||
await requestOptions?.onResponse?.(
|
||||
{ status: response.status, headers: headersToRecord(response.headers) },
|
||||
model,
|
||||
);
|
||||
|
||||
type Block = (ThinkingContent | TextContent | (ToolCall & { partialJson: string })) & {
|
||||
index: number;
|
||||
|
||||
@@ -590,43 +590,6 @@ describe("consumeGoogleGenerateContentStream", () => {
|
||||
});
|
||||
|
||||
describe("runGoogleGenerateContentLifecycle", () => {
|
||||
it("reports SDK stream acceptance without fabricated HTTP metadata", async () => {
|
||||
const onProviderAccepted = vi.fn();
|
||||
|
||||
const { result } = await runGoogleFixture(
|
||||
[googleResponse({ parts: [{ text: "ok" }], finishReason: FinishReason.STOP })],
|
||||
{ options: { onProviderAccepted } },
|
||||
);
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(onProviderAccepted).toHaveBeenCalledWith({ kind: "provider_stream_opened" }, model);
|
||||
});
|
||||
|
||||
it("closes an unread SDK stream without waiting when acceptance fails", async () => {
|
||||
const close = vi.fn(() => new Promise<IteratorResult<GenerateContentResponse>>(() => {}));
|
||||
const googleStream = {
|
||||
next: vi.fn(),
|
||||
return: close,
|
||||
throw: vi.fn(),
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as AsyncGenerator<GenerateContentResponse>;
|
||||
|
||||
const { result } = await runGoogleFixture([], {
|
||||
options: {
|
||||
onProviderAccepted: () => Promise.reject(new Error("acceptance callback failed")),
|
||||
},
|
||||
generateContentStream: async () => googleStream,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
stopReason: "error",
|
||||
errorMessage: "acceptance callback failed",
|
||||
});
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["google-generative-ai", "google-vertex"] as const)(
|
||||
"rejects an unfinished %s stream instead of silently completing partial output",
|
||||
async (api) => {
|
||||
|
||||
@@ -19,7 +19,6 @@ import { googleFlashSupportsMinimalThinking } from "../transports/google-thinkin
|
||||
import {
|
||||
assignTransportErrorDetails,
|
||||
coerceTransportToolCallArguments,
|
||||
notifyProviderStreamOpened,
|
||||
transportAbortError,
|
||||
} from "../transports/transport-stream-shared.js";
|
||||
import type {
|
||||
@@ -422,7 +421,7 @@ export async function runGoogleGenerateContentLifecycle<T extends GoogleApiType>
|
||||
stream: AssistantMessageEventStream;
|
||||
model: Model<T>;
|
||||
output: AssistantMessage;
|
||||
options?: Pick<StreamOptions, "signal" | "onPayload" | "onProviderAccepted">;
|
||||
options?: Pick<StreamOptions, "signal" | "onPayload">;
|
||||
createClient: () => GoogleGenerateContentClient;
|
||||
buildParams: () => GenerateContentParameters;
|
||||
nextToolCallId: (name: string | undefined) => string;
|
||||
@@ -437,16 +436,8 @@ export async function runGoogleGenerateContentLifecycle<T extends GoogleApiType>
|
||||
requestParams = nextParams as GenerateContentParameters;
|
||||
}
|
||||
const googleStream = await client.models.generateContentStream(requestParams);
|
||||
const googleIterator = googleStream[Symbol.asyncIterator]();
|
||||
try {
|
||||
await notifyProviderStreamOpened({ options, model });
|
||||
} catch (error) {
|
||||
// Cleanup is best effort; callback failure must not wait on an unread SDK stream.
|
||||
void Promise.resolve(googleIterator.return?.()).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
await consumeGoogleGenerateContentStream({
|
||||
chunks: { [Symbol.asyncIterator]: () => googleIterator },
|
||||
chunks: googleStream,
|
||||
model,
|
||||
output,
|
||||
stream,
|
||||
|
||||
@@ -9,7 +9,6 @@ const mistralMockState = vi.hoisted(() => ({
|
||||
payloads: [] as unknown[],
|
||||
requestOptions: [] as unknown[],
|
||||
randomUUIDs: [] as string[],
|
||||
requestThroughHttpClient: false,
|
||||
streamError: new Error("stop before network") as unknown,
|
||||
streamResult: undefined as unknown,
|
||||
}));
|
||||
@@ -28,10 +27,7 @@ vi.mock("@mistralai/mistralai", async () => {
|
||||
return {
|
||||
...actual,
|
||||
Mistral: class MockMistral {
|
||||
private readonly config: unknown;
|
||||
|
||||
constructor(config: unknown) {
|
||||
this.config = config;
|
||||
mistralMockState.configs.push(config);
|
||||
}
|
||||
|
||||
@@ -39,19 +35,6 @@ vi.mock("@mistralai/mistralai", async () => {
|
||||
stream: vi.fn(async (payload: unknown, requestOptions: unknown) => {
|
||||
mistralMockState.payloads.push(payload);
|
||||
mistralMockState.requestOptions.push(requestOptions);
|
||||
if (mistralMockState.requestThroughHttpClient) {
|
||||
const httpClient = (
|
||||
this.config as {
|
||||
httpClient?: { request(request: Request): Promise<Response> };
|
||||
}
|
||||
).httpClient;
|
||||
const response = await httpClient?.request(new Request("https://api.mistral.ai/chat"));
|
||||
if (response && !response.ok) {
|
||||
throw Object.assign(new Error(`Mistral HTTP ${response.status}`), {
|
||||
statusCode: response.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (mistralMockState.streamResult !== undefined) {
|
||||
return mistralMockState.streamResult;
|
||||
}
|
||||
@@ -236,7 +219,6 @@ describe("Mistral provider", () => {
|
||||
mistralMockState.payloads = [];
|
||||
mistralMockState.requestOptions = [];
|
||||
mistralMockState.randomUUIDs = [];
|
||||
mistralMockState.requestThroughHttpClient = false;
|
||||
mistralMockState.streamError = new Error("stop before network");
|
||||
mistralMockState.streamResult = undefined;
|
||||
});
|
||||
@@ -245,130 +227,6 @@ describe("Mistral provider", () => {
|
||||
configureAiTransportHost({});
|
||||
});
|
||||
|
||||
it("reports the real HTTP response captured by the Mistral HTTPClient hook", async () => {
|
||||
mistralMockState.requestThroughHttpClient = true;
|
||||
mistralMockState.streamResult = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
data: {
|
||||
id: "resp-http-ack",
|
||||
model: "mistral-large-latest",
|
||||
choices: [{ finishReason: "stop", delta: { content: "ok" } }],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const hostFetch = vi.fn<typeof fetch>(
|
||||
async () =>
|
||||
new Response("stream", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream",
|
||||
"x-mistral-request-id": "req-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
configureAiTransportHost({ buildModelFetch: () => hostFetch });
|
||||
const onProviderAccepted = vi.fn();
|
||||
const onResponse = vi.fn();
|
||||
|
||||
const result = await runSimpleMistralFixture(context, {
|
||||
onProviderAccepted,
|
||||
onResponse,
|
||||
});
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(onProviderAccepted).toHaveBeenCalledWith(
|
||||
{
|
||||
kind: "http_response",
|
||||
status: 200,
|
||||
headers: expect.objectContaining({
|
||||
"content-type": "text/event-stream",
|
||||
"x-mistral-request-id": "req-1",
|
||||
}),
|
||||
},
|
||||
expect.objectContaining({ provider: "mistral" }),
|
||||
);
|
||||
expect(onResponse).toHaveBeenCalledWith(
|
||||
{
|
||||
status: 200,
|
||||
headers: expect.objectContaining({ "x-mistral-request-id": "req-1" }),
|
||||
},
|
||||
expect.objectContaining({ provider: "mistral" }),
|
||||
);
|
||||
expect(hostFetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("cancels an unread Mistral stream when provider acceptance fails", async () => {
|
||||
mistralMockState.requestThroughHttpClient = true;
|
||||
const cancel = vi.fn(async () => undefined);
|
||||
mistralMockState.streamResult = {
|
||||
cancel,
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
data: {
|
||||
id: "resp-http-ack",
|
||||
model: "mistral-large-latest",
|
||||
choices: [{ finishReason: "stop", delta: { content: "ok" } }],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
configureAiTransportHost({
|
||||
buildModelFetch: () => async () => new Response("stream", { status: 200 }),
|
||||
});
|
||||
const hookError = new Error("acceptance callback failed");
|
||||
|
||||
const result = await runSimpleMistralFixture(context, {
|
||||
onProviderAccepted: () => Promise.reject(hookError),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
stopReason: "error",
|
||||
errorMessage: "acceptance callback failed",
|
||||
});
|
||||
expect(cancel).toHaveBeenCalledWith(hookError);
|
||||
});
|
||||
|
||||
it("reports a rejected HTTP response without marking it accepted", async () => {
|
||||
mistralMockState.requestThroughHttpClient = true;
|
||||
const hostFetch = vi.fn<typeof fetch>(
|
||||
async () =>
|
||||
new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: { "x-mistral-request-id": "req-rejected" },
|
||||
}),
|
||||
);
|
||||
configureAiTransportHost({ buildModelFetch: () => hostFetch });
|
||||
const onProviderAccepted = vi.fn();
|
||||
const onResponse = vi.fn();
|
||||
|
||||
const result = await runSimpleMistralFixture(context, {
|
||||
onProviderAccepted,
|
||||
onResponse,
|
||||
});
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(onProviderAccepted).not.toHaveBeenCalled();
|
||||
expect(onResponse).toHaveBeenCalledWith(
|
||||
{
|
||||
status: 429,
|
||||
headers: expect.objectContaining({ "x-mistral-request-id": "req-rejected" }),
|
||||
},
|
||||
expect.objectContaining({ provider: "mistral" }),
|
||||
);
|
||||
expect(hostFetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not report acceptance when SDK stream setup fails", async () => {
|
||||
const onProviderAccepted = vi.fn();
|
||||
|
||||
const result = await runSimpleMistralFixture(context, { onProviderAccepted });
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(onProviderAccepted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards simple stop sequences to Mistral stop", async () => {
|
||||
const result = await runSimpleMistralFixture(context, {
|
||||
stop: ["STOP"],
|
||||
|
||||
@@ -13,10 +13,7 @@ import { getEnvApiKey } from "../env-api-keys.js";
|
||||
import { getAiTransportHost } from "../host.js";
|
||||
import { calculateCost, clampThinkingLevel } from "../model-utils.js";
|
||||
import { transformProviderMessages as transformMessages } from "../provider-transcript-transform.js";
|
||||
import {
|
||||
notifyProviderHttpResponse,
|
||||
transportAbortError,
|
||||
} from "../transports/transport-stream-shared.js";
|
||||
import { transportAbortError } from "../transports/transport-stream-shared.js";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Context,
|
||||
@@ -141,28 +138,24 @@ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptio
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
|
||||
const boundedFetcher = createBoundedMistralFetcher(
|
||||
MISTRAL_STREAM_BODY_MAX_BYTES,
|
||||
getAiTransportHost().buildModelFetch(model) ?? fetch,
|
||||
);
|
||||
let mistralResponse: Response | undefined;
|
||||
let reportedResponse: Response | undefined;
|
||||
const httpClient = new HTTPClient({ fetcher: boundedFetcher });
|
||||
httpClient.addHook("response", async (response) => {
|
||||
mistralResponse = response;
|
||||
if (!response.ok) {
|
||||
await notifyProviderHttpResponse({ options, response, model });
|
||||
reportedResponse = response;
|
||||
}
|
||||
});
|
||||
// Intentionally per-request: avoids shared SDK mutable state across concurrent consumers.
|
||||
const mistral = new Mistral({
|
||||
apiKey,
|
||||
serverURL: model.baseUrl,
|
||||
// Bound the streamed Mistral response body at 16 MiB so a hostile or
|
||||
// malfunctioning endpoint cannot exhaust memory. The HTTPClient is the
|
||||
// SDK's public fetch and response-hook boundary for every chat.stream attempt.
|
||||
httpClient,
|
||||
// malfunctioning endpoint cannot exhaust memory. The fetcher is
|
||||
// injected via the SDK's `HTTPClient` (see
|
||||
// `@mistralai/mistralai/lib/sdks.ts` `ClientSDK` constructor: when
|
||||
// `httpClient` is passed, `ClientSDK.#httpClient` is set from it and
|
||||
// every `chat.stream` / `complete` call routes through
|
||||
// `HTTPClient.request` → `this.fetcher(req)`).
|
||||
// Mistral accepts HTTPClient.fetcher, so compose guarded egress with the byte cap.
|
||||
httpClient: new HTTPClient({
|
||||
fetcher: createBoundedMistralFetcher(
|
||||
MISTRAL_STREAM_BODY_MAX_BYTES,
|
||||
getAiTransportHost().buildModelFetch(model) ?? fetch,
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const normalizeMistralToolCallId = createMistralToolCallIdNormalizer();
|
||||
@@ -185,16 +178,6 @@ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptio
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
if (mistralResponse && mistralResponse !== reportedResponse) {
|
||||
try {
|
||||
await notifyProviderHttpResponse({ options, response: mistralResponse, model });
|
||||
} catch (error) {
|
||||
// The SDK EventStream owns the locked response reader after chat.stream resolves.
|
||||
// Cancellation is best effort and must not delay the lifecycle callback failure.
|
||||
void mistralStream.cancel(error).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
stream.push({ type: "start", partial: output });
|
||||
await consumeChatStream(model, output, stream, mistralStream);
|
||||
|
||||
|
||||
@@ -151,57 +151,6 @@ describe("OpenAI ChatGPT Responses inference streaming", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports acceptance before the default WebSocket stream starts", async () => {
|
||||
class AcceptedWebSocket extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
queueMicrotask(() => this.dispatchEvent(new Event("open")));
|
||||
}
|
||||
|
||||
send(): void {
|
||||
queueMicrotask(() => {
|
||||
this.dispatchEvent(
|
||||
Object.assign(new Event("message"), {
|
||||
data: JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_ws_accepted",
|
||||
status: "completed",
|
||||
output: [],
|
||||
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
const order: string[] = [];
|
||||
const onProviderAccepted = vi.fn(async () => {
|
||||
order.push("accepted");
|
||||
});
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("WebSocket", AcceptedWebSocket);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const stream = streamOpenAICodexResponses(model, context, {
|
||||
apiKey: createJwt({
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: "acct-1" },
|
||||
}),
|
||||
onProviderAccepted,
|
||||
});
|
||||
for await (const event of stream) {
|
||||
order.push(event.type);
|
||||
}
|
||||
|
||||
expect(order).toEqual(["accepted", "start", "done"]);
|
||||
expect(onProviderAccepted).toHaveBeenCalledWith({ kind: "provider_stream_opened" }, model);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits an error for a content-filtered incomplete WebSocket turn", async () => {
|
||||
class ContentFilteredWebSocket extends EventTarget {
|
||||
constructor() {
|
||||
|
||||
@@ -544,47 +544,6 @@ describe("ChatGPT Responses encrypted replay recovery", () => {
|
||||
expect(onCompactionRejected).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("WebSocket commits stripped compaction before a provider acceptance callback fails", async () => {
|
||||
const context = createReplayContext("compaction");
|
||||
const onCompactionRejected = vi.fn();
|
||||
const observations: ResponsesPromptObservation[] = [];
|
||||
const scripted = installScriptedWebSocket([
|
||||
{ events: [invalidEncryptedEvent()] },
|
||||
{ events: [completionEvent("resp_ws_hook_failure")] },
|
||||
]);
|
||||
const onProviderAccepted = vi.fn(async () => {
|
||||
if (scripted.requests.length >= 2) {
|
||||
throw new Error("acceptance callback failed");
|
||||
}
|
||||
});
|
||||
const options = createObservedOptions(
|
||||
{
|
||||
apiKey: createJwt(),
|
||||
transport: "websocket" as const,
|
||||
onCompactionRejected,
|
||||
onProviderAccepted,
|
||||
...REPLAY_IDENTITY,
|
||||
},
|
||||
observations,
|
||||
);
|
||||
|
||||
const result = await streamOpenAICodexResponses(model, context, options).result();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
stopReason: "error",
|
||||
errorMessage: "acceptance callback failed",
|
||||
providerReplay: { type: "openai-responses-compaction-suppression" },
|
||||
});
|
||||
expect(scripted.requests).toHaveLength(2);
|
||||
expect(hasInputType(requireItem(scripted.requests, 0), "compaction")).toBe(true);
|
||||
expect(hasInputType(requireItem(scripted.requests, 1), "compaction")).toBe(false);
|
||||
expect(observations.map((entry) => entry.payloadVariant)).toEqual([
|
||||
"initial",
|
||||
"compaction-stripped",
|
||||
]);
|
||||
expect(onCompactionRejected).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("WebSocket preserves compaction when reasoning-stripped recovery succeeds", async () => {
|
||||
const context = createReplayContext("mixed");
|
||||
const observations: ResponsesPromptObservation[] = [];
|
||||
|
||||
@@ -92,13 +92,9 @@ describe("streamOpenAICodexResponses retry classification", () => {
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
|
||||
const onProviderAccepted = vi.fn();
|
||||
const onResponse = vi.fn();
|
||||
const options = {
|
||||
apiKey: jwt,
|
||||
transport: "sse" as const,
|
||||
onProviderAccepted,
|
||||
onResponse,
|
||||
};
|
||||
responsesPromptObserver.set(options, (observation) => observations.push(observation));
|
||||
|
||||
@@ -110,8 +106,6 @@ describe("streamOpenAICodexResponses retry classification", () => {
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(onProviderAccepted).not.toHaveBeenCalled();
|
||||
expect(onResponse.mock.calls.map(([response]) => response.status)).toEqual([503, 401]);
|
||||
expect(observations).toHaveLength(2);
|
||||
expect(observations.every((entry) => entry.egress === "native-codex-sse")).toBe(true);
|
||||
expect(observations.every((entry) => entry.payloadVariant === "initial")).toBe(true);
|
||||
|
||||
@@ -33,12 +33,8 @@ import {
|
||||
type ResponsesEncryptedContentAttempt,
|
||||
} from "../transports/openai-responses-replay-internal.js";
|
||||
import { processResponsesStream } from "../transports/openai-responses-stream-internal.js";
|
||||
import { createOpenAIResponseHook } from "../transports/openai-transport-shared.js";
|
||||
import {
|
||||
createOpenAIProviderAcceptanceHook,
|
||||
createOpenAIResponseHook,
|
||||
} from "../transports/openai-transport-shared.js";
|
||||
import {
|
||||
notifyProviderStreamOpened,
|
||||
transportAbortError,
|
||||
withProviderResponseHook,
|
||||
} from "../transports/transport-stream-shared.js";
|
||||
@@ -363,11 +359,9 @@ export const streamOpenAICodexResponses: StreamFunction<
|
||||
output,
|
||||
stream,
|
||||
model,
|
||||
() => {
|
||||
websocketStarted = true;
|
||||
},
|
||||
() => {
|
||||
commitSemanticAttempt(activeAttempt);
|
||||
websocketStarted = true;
|
||||
},
|
||||
requestOptions,
|
||||
firstEventAbort.abort,
|
||||
@@ -588,7 +582,7 @@ export const streamOpenAICodexResponses: StreamFunction<
|
||||
stream: mapCodexEvents(parseOpenAIChatGptResponsesSse(response)),
|
||||
signal: firstEventAbort.signal,
|
||||
abort: firstEventAbort.abort,
|
||||
hook: createOpenAIProviderAcceptanceHook(options, response, model),
|
||||
hook: createOpenAIResponseHook(options?.onResponse, response, model),
|
||||
onReady: () => stream.push({ type: "start", partial: output }),
|
||||
});
|
||||
await processResponsesStream(hookedResponseStream, output, stream, model, {
|
||||
@@ -1459,17 +1453,13 @@ async function* startWebSocketOutputOnFirstEvent(
|
||||
events: AsyncIterable<ResponseStreamEvent>,
|
||||
output: AssistantMessage,
|
||||
stream: AssistantMessageEventStream,
|
||||
onFirstProviderEvent: () => void,
|
||||
onProviderAccepted: () => Promise<void>,
|
||||
onStart: () => void,
|
||||
): AsyncGenerator<ResponseStreamEvent> {
|
||||
let started = false;
|
||||
for await (const event of events) {
|
||||
if (!started) {
|
||||
started = true;
|
||||
onFirstProviderEvent();
|
||||
onStart();
|
||||
await onProviderAccepted();
|
||||
stream.push({ type: "start", partial: output });
|
||||
}
|
||||
yield event;
|
||||
@@ -1483,7 +1473,6 @@ async function processWebSocketStream(
|
||||
output: AssistantMessage,
|
||||
stream: AssistantMessageEventStream,
|
||||
model: Model<"openai-chatgpt-responses">,
|
||||
onFirstProviderEvent: () => void,
|
||||
onStart: () => void,
|
||||
options?: OpenAICodexResponsesOptions,
|
||||
abortFirstEventStream?: (reason: Error) => void,
|
||||
@@ -1520,8 +1509,6 @@ async function processWebSocketStream(
|
||||
mapCodexEvents(parseWebSocket(socket, options?.signal)),
|
||||
output,
|
||||
stream,
|
||||
onFirstProviderEvent,
|
||||
() => notifyProviderStreamOpened({ options, model }),
|
||||
onStart,
|
||||
),
|
||||
output,
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "../transports/openai-completions-compat.js";
|
||||
import { resolveOpenAIReasoningEffortMap } from "../transports/openai-reasoning-compat.js";
|
||||
import {
|
||||
createOpenAIProviderAcceptanceHook,
|
||||
createOpenAIResponseHook,
|
||||
isOpenAICompletionsThinkingEnabled,
|
||||
parseOpenAICompletionsUsage,
|
||||
readOpenAICompletionsContentDeltas,
|
||||
@@ -184,7 +184,7 @@ export const streamOpenAICompletions: StreamFunction<
|
||||
stream: openaiStream,
|
||||
signal: firstEventAbort.signal,
|
||||
abort: firstEventAbort.abort,
|
||||
hook: createOpenAIProviderAcceptanceHook(options, response, model),
|
||||
hook: createOpenAIResponseHook(options?.onResponse, response, model),
|
||||
onReady: () => stream.push({ type: "start", partial: output }),
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
convertProviderResponsesMessages,
|
||||
} from "../transports/openai-responses-replay-internal.js";
|
||||
import { processResponsesStream } from "../transports/openai-responses-stream-internal.js";
|
||||
import { createOpenAIProviderAcceptanceHook } from "../transports/openai-transport-shared.js";
|
||||
import { createOpenAIResponseHook } from "../transports/openai-transport-shared.js";
|
||||
import {
|
||||
transportAbortError,
|
||||
withProviderResponseHook,
|
||||
@@ -92,13 +92,7 @@ type ResponsesStreamClient = {
|
||||
|
||||
type ResponsesLifecycleStreamOptions = Pick<
|
||||
StreamOptions,
|
||||
| "signal"
|
||||
| "timeoutMs"
|
||||
| "maxRetries"
|
||||
| "onPayload"
|
||||
| "onProviderAccepted"
|
||||
| "onResponse"
|
||||
| "sessionId"
|
||||
"signal" | "timeoutMs" | "maxRetries" | "onPayload" | "onResponse" | "sessionId"
|
||||
> &
|
||||
Pick<BaseOpenAIStreamOptions, "authProfileId" | "onCompactionRejected"> &
|
||||
FirstStreamEventInternalOptions;
|
||||
@@ -302,7 +296,7 @@ export async function runResponsesStreamLifecycle<TApi extends Api>(params: {
|
||||
stream: openaiStream,
|
||||
signal: firstEventAbort.signal,
|
||||
abort: firstEventAbort.abort,
|
||||
hook: createOpenAIProviderAcceptanceHook(options, response, model),
|
||||
hook: createOpenAIResponseHook(options?.onResponse, response, model),
|
||||
onReady: () => stream.push({ type: "start", partial: output }),
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ export function buildBaseOptions(
|
||||
promptCacheKey: options?.promptCacheKey,
|
||||
headers: options?.headers,
|
||||
onPayload: options?.onPayload,
|
||||
onProviderAccepted: options?.onProviderAccepted,
|
||||
onResponse: options?.onResponse,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
firstEventTimeoutMs: firstEventOptions?.firstEventTimeoutMs,
|
||||
|
||||
@@ -1129,25 +1129,18 @@ describe("anthropic transport stream", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const onProviderAccepted = vi.fn();
|
||||
const onResponse = vi.fn();
|
||||
const result = await runTransportStream(
|
||||
makeAnthropicTransportModel(),
|
||||
{
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
} as AnthropicStreamContext,
|
||||
{ apiKey: "test-api-key", onProviderAccepted, onResponse } as AnthropicStreamOptions,
|
||||
{ apiKey: "test-api-key" } as AnthropicStreamOptions,
|
||||
);
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toBe(
|
||||
'HTTP 429: {"type":"error","error":{"type":"rate_limit_error","message":"Number of request tokens exceeded the per-minute rate limit."}}; Retry-After: 30 seconds',
|
||||
);
|
||||
expect(onProviderAccepted).not.toHaveBeenCalled();
|
||||
expect(onResponse).toHaveBeenCalledWith(
|
||||
{ status: 429, headers: { "content-type": "text/plain;charset=UTF-8", "retry-after": "30" } },
|
||||
expect.objectContaining({ provider: "anthropic" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds streamed Anthropic error responses without content-length", async () => {
|
||||
@@ -3801,33 +3794,6 @@ describe("anthropic transport stream", () => {
|
||||
expect(cancelReason).toBe(abortReason);
|
||||
});
|
||||
|
||||
it("cancels an unread SSE body when provider acceptance fails", async () => {
|
||||
let cancelCalled = false;
|
||||
guardedFetchMock.mockResolvedValueOnce(
|
||||
createOpenRawSseResponse({
|
||||
body: "",
|
||||
onCancel: () => {
|
||||
cancelCalled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runTransportStream(
|
||||
makeAnthropicTransportModel(),
|
||||
{ messages: [{ role: "user", content: "hello" }] } as AnthropicStreamContext,
|
||||
{
|
||||
apiKey: "sk-ant-api",
|
||||
onProviderAccepted: () => Promise.reject(new Error("acceptance callback failed")),
|
||||
} as AnthropicStreamOptions,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
stopReason: "error",
|
||||
errorMessage: "acceptance callback failed",
|
||||
});
|
||||
await vi.waitFor(() => expect(cancelCalled).toBe(true));
|
||||
});
|
||||
|
||||
it("cancels open SSE bodies when Anthropic stream consumers throw", async () => {
|
||||
let cancelCalled = false;
|
||||
guardedFetchMock.mockResolvedValueOnce(
|
||||
@@ -4367,12 +4333,10 @@ describe("anthropic transport stream", () => {
|
||||
]),
|
||||
);
|
||||
const streamFn = createAnthropicMessagesTransportStreamFn();
|
||||
const onProviderAccepted = vi.fn();
|
||||
const onResponse = vi.fn();
|
||||
const stream = streamFn(
|
||||
makeAnthropicTransportModel(),
|
||||
{ messages: [{ role: "user", content: "hi" }] } as AnthropicStreamContext,
|
||||
{ apiKey: "sk-ant-api", onProviderAccepted, onResponse } as AnthropicStreamOptions,
|
||||
{ apiKey: "sk-ant-api" } as AnthropicStreamOptions,
|
||||
);
|
||||
|
||||
const eventTypes: string[] = [];
|
||||
@@ -4383,18 +4347,6 @@ describe("anthropic transport stream", () => {
|
||||
const startIndex = eventTypes.indexOf("start");
|
||||
expect(startIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(eventTypes.slice(0, startIndex).some((t) => t === "error")).toBe(false);
|
||||
expect(onProviderAccepted).toHaveBeenCalledWith(
|
||||
{
|
||||
kind: "http_response",
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
},
|
||||
expect.objectContaining({ provider: "anthropic" }),
|
||||
);
|
||||
expect(onResponse).toHaveBeenCalledWith(
|
||||
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
||||
expect.objectContaining({ provider: "anthropic" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits error without a preceding start event when SSE error arrives before message_start", async () => {
|
||||
@@ -4409,12 +4361,10 @@ describe("anthropic transport stream", () => {
|
||||
),
|
||||
);
|
||||
const streamFn = createAnthropicMessagesTransportStreamFn();
|
||||
const onProviderAccepted = vi.fn();
|
||||
const onResponse = vi.fn();
|
||||
const stream = streamFn(
|
||||
makeAnthropicTransportModel(),
|
||||
{ messages: [{ role: "user", content: "hi" }] } as AnthropicStreamContext,
|
||||
{ apiKey: "sk-ant-api", onProviderAccepted, onResponse } as AnthropicStreamOptions,
|
||||
{ apiKey: "sk-ant-api" } as AnthropicStreamOptions,
|
||||
);
|
||||
|
||||
const eventTypes: string[] = [];
|
||||
@@ -4426,18 +4376,6 @@ describe("anthropic transport stream", () => {
|
||||
// surfaces the SSE error as an explicit "error" event or silently ends the
|
||||
// stream (a timing artefact of synchronous mock SSE delivery).
|
||||
expect(eventTypes).not.toContain("start");
|
||||
expect(onProviderAccepted).toHaveBeenCalledWith(
|
||||
{
|
||||
kind: "http_response",
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
},
|
||||
expect.objectContaining({ provider: "anthropic" }),
|
||||
);
|
||||
expect(onResponse).toHaveBeenCalledWith(
|
||||
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
||||
expect.objectContaining({ provider: "anthropic" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -107,7 +107,6 @@ import {
|
||||
failTransportStream,
|
||||
finalizeTransportStream,
|
||||
mergeTransportHeaders,
|
||||
notifyProviderHttpResponse,
|
||||
sanitizeNonEmptyTransportPayloadText,
|
||||
sanitizeTransportPayloadText,
|
||||
transportAbortError,
|
||||
@@ -144,10 +143,7 @@ type AnthropicMessagesClient = {
|
||||
stream(
|
||||
params: Record<string, unknown>,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<{
|
||||
response: Response;
|
||||
stream: AsyncIterable<Record<string, unknown>> | Iterable<Record<string, unknown>>;
|
||||
}>;
|
||||
): AsyncIterable<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -745,7 +741,7 @@ function createAnthropicMessagesClient(params: {
|
||||
const url = resolveAnthropicMessagesUrl(params.baseURL);
|
||||
return {
|
||||
messages: {
|
||||
async stream(body: Record<string, unknown>, options?: { signal?: AbortSignal }) {
|
||||
async *stream(body: Record<string, unknown>, options?: { signal?: AbortSignal }) {
|
||||
const headers = mergeTransportHeaders(
|
||||
{
|
||||
"content-type": "application/json",
|
||||
@@ -761,10 +757,14 @@ function createAnthropicMessagesClient(params: {
|
||||
body: JSON.stringify(body),
|
||||
signal: options?.signal,
|
||||
});
|
||||
return {
|
||||
response,
|
||||
stream: response.body ? parseAnthropicSseBody(response.body, options?.signal) : [],
|
||||
};
|
||||
if (!response.ok) {
|
||||
const detail = await readAnthropicMessagesErrorBodySnippet(response);
|
||||
throw new Error(formatAnthropicMessagesHttpError(response, detail));
|
||||
}
|
||||
if (!response.body) {
|
||||
return;
|
||||
}
|
||||
yield* parseAnthropicSseBody(response.body, options?.signal);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1094,8 +1094,6 @@ function resolveAnthropicTransportOptions(
|
||||
sessionId: options?.sessionId,
|
||||
headers: options?.headers,
|
||||
onPayload: options?.onPayload,
|
||||
onProviderAccepted: options?.onProviderAccepted,
|
||||
onResponse: options?.onResponse,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
metadata: options?.metadata,
|
||||
interleavedThinking: options?.interleavedThinking,
|
||||
@@ -1193,15 +1191,10 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
|
||||
params = nextParams as Record<string, unknown>;
|
||||
}
|
||||
applyClaudeRequestContract(params, model);
|
||||
const { response, stream: anthropicStream } = await client.messages.stream(
|
||||
const anthropicStream = client.messages.stream(
|
||||
{ ...params, stream: true },
|
||||
transportOptions.signal ? { signal: transportOptions.signal } : undefined,
|
||||
);
|
||||
await notifyProviderHttpResponse({ options: transportOptions, response, model });
|
||||
if (!response.ok) {
|
||||
const detail = await readAnthropicMessagesErrorBodySnippet(response);
|
||||
throw new Error(formatAnthropicMessagesHttpError(response, detail));
|
||||
}
|
||||
const blocks = output.content;
|
||||
const blockIndexes = new Map<number, number>();
|
||||
const compactionCapture = createCompactionCapture(output, model, transportOptions);
|
||||
|
||||
@@ -116,21 +116,12 @@ describe.each([
|
||||
const hookCompleted = new Promise<void>((resolve) => {
|
||||
continueHook = resolve;
|
||||
});
|
||||
const onProviderAccepted = vi.fn<NonNullable<StreamOptions["onProviderAccepted"]>>(
|
||||
(acceptance) => {
|
||||
order.push(`accepted:${acceptance.kind}`);
|
||||
},
|
||||
);
|
||||
const onResponse = vi.fn<NonNullable<StreamOptions["onResponse"]>>(async () => {
|
||||
order.push("hook:start");
|
||||
await hookCompleted;
|
||||
order.push("hook:end");
|
||||
});
|
||||
const stream = createStream(model, context, {
|
||||
apiKey: "fixture-token",
|
||||
onProviderAccepted,
|
||||
onResponse,
|
||||
});
|
||||
const stream = createStream(model, context, { apiKey: "fixture-token", onResponse });
|
||||
const consume = (async () => {
|
||||
for await (const event of stream) {
|
||||
order.push(event.type);
|
||||
@@ -138,19 +129,7 @@ describe.each([
|
||||
})();
|
||||
|
||||
await vi.waitFor(() => expect(onResponse).toHaveBeenCalledOnce());
|
||||
expect(order).toEqual(["accepted:http_response", "hook:start"]);
|
||||
expect(onProviderAccepted).toHaveBeenCalledWith(
|
||||
{
|
||||
kind: "http_response",
|
||||
status: 202,
|
||||
headers: {
|
||||
"content-type": "text/event-stream",
|
||||
"x-ratelimit-remaining-requests": "42",
|
||||
"x-request-id": "req_observable",
|
||||
},
|
||||
},
|
||||
model,
|
||||
);
|
||||
expect(order).toEqual(["hook:start"]);
|
||||
expect(onResponse).toHaveBeenCalledWith(
|
||||
{
|
||||
status: 202,
|
||||
@@ -166,12 +145,7 @@ describe.each([
|
||||
continueHook();
|
||||
await consume;
|
||||
expect((await stream.result()).stopReason).toBe("stop");
|
||||
expect(order.slice(0, 4)).toEqual([
|
||||
"accepted:http_response",
|
||||
"hook:start",
|
||||
"hook:end",
|
||||
"start",
|
||||
]);
|
||||
expect(order.slice(0, 3)).toEqual(["hook:start", "hook:end", "start"]);
|
||||
});
|
||||
|
||||
it.each(["throw", "reject"] as const)(
|
||||
@@ -203,32 +177,6 @@ describe.each([
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves an acceptance hook failure and closes the unread request", async () => {
|
||||
const lifecycle = installResponse();
|
||||
const hookError = new Error("provider acceptance hook failed");
|
||||
const onProviderAccepted = vi.fn(() => Promise.reject(hookError));
|
||||
const onResponse = vi.fn();
|
||||
const stream = createStream(model, context, {
|
||||
apiKey: "fixture-token",
|
||||
onProviderAccepted,
|
||||
onResponse,
|
||||
});
|
||||
const eventTypes: string[] = [];
|
||||
|
||||
for await (const event of stream) {
|
||||
eventTypes.push(event.type);
|
||||
}
|
||||
|
||||
expect(await stream.result()).toMatchObject({
|
||||
stopReason: "error",
|
||||
errorMessage: "provider acceptance hook failed",
|
||||
});
|
||||
expect(onProviderAccepted).toHaveBeenCalledOnce();
|
||||
expect(onResponse).not.toHaveBeenCalled();
|
||||
expect(eventTypes).toEqual(["error"]);
|
||||
expect(lifecycle.requestAborted).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("applies the first-event timeout while the hook is pending", async () => {
|
||||
const lifecycle = installResponse();
|
||||
const onFirstEventTimeout = vi.fn();
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
resolveCodeModeResponsesVisibleToolNames,
|
||||
} from "./openai-transport-params.js";
|
||||
import {
|
||||
createOpenAIProviderAcceptanceHook,
|
||||
createOpenAIResponseHook,
|
||||
type MutableAssistantOutput,
|
||||
type OpenAIModeModel,
|
||||
@@ -272,9 +271,7 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn {
|
||||
stream: responseStream,
|
||||
signal: firstEventAbort.signal,
|
||||
abort: firstEventAbort.abort,
|
||||
hook: options?.onProviderAccepted
|
||||
? createOpenAIProviderAcceptanceHook(options, response, model)
|
||||
: createOpenAIResponseHook(options?.onResponse, response, model),
|
||||
hook: createOpenAIResponseHook(options?.onResponse, response, model),
|
||||
onReady: () => stream.push({ type: "start", partial: output }),
|
||||
});
|
||||
await processCompletionsStream(hookedResponseStream, output, model, stream, {
|
||||
|
||||
@@ -70,14 +70,13 @@ import {
|
||||
isOpenAICodexResponsesModel,
|
||||
resolveCodeModeResponsesVisibleToolNames,
|
||||
} from "./openai-transport-params.js";
|
||||
import { createOpenAIProviderAcceptanceHook, log } from "./openai-transport-shared.js";
|
||||
import { createOpenAIResponseHook, log } from "./openai-transport-shared.js";
|
||||
import { sanitizeResponsesImagePayload } from "./responses-image-payload-sanitizer.js";
|
||||
import {
|
||||
createWritableTransportEventStream,
|
||||
failTransportStream,
|
||||
finalizeTransportStream,
|
||||
mergeTransportMetadata,
|
||||
notifyProviderStreamOpened,
|
||||
transportAbortError,
|
||||
withProviderResponseHook,
|
||||
} from "./transport-stream-shared.js";
|
||||
@@ -449,7 +448,7 @@ function createResponsesTransportExecutor(config: ResponsesTransportExecutorOpti
|
||||
stream: observeResponsesStream(rawResponseStream, model, requestStartedAt),
|
||||
signal: firstEvent.signal,
|
||||
abort: firstEvent.abort,
|
||||
hook: createOpenAIProviderAcceptanceHook(options, response, model),
|
||||
hook: createOpenAIResponseHook(options?.onResponse, response, model),
|
||||
onReady: () => {
|
||||
emitModelTransportDebug(
|
||||
log,
|
||||
@@ -504,13 +503,8 @@ function createResponsesTransportExecutor(config: ResponsesTransportExecutorOpti
|
||||
);
|
||||
responseStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
let providerAccepted = false;
|
||||
try {
|
||||
for await (const event of websocket.stream) {
|
||||
if (!providerAccepted) {
|
||||
providerAccepted = true;
|
||||
await notifyProviderStreamOpened({ options, model });
|
||||
}
|
||||
startStream();
|
||||
yield event;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type AssistantMessage,
|
||||
type Context,
|
||||
type Model,
|
||||
type StreamOptions,
|
||||
} from "@openclaw/llm-core";
|
||||
import { WebSocketError } from "openai/resources/responses/internal-base.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -269,7 +268,6 @@ async function run(
|
||||
headers?: Record<string, string>;
|
||||
observations?: ResponsesPromptObservation[];
|
||||
onCompactionRejected?: () => void;
|
||||
onProviderAccepted?: NonNullable<StreamOptions["onProviderAccepted"]>;
|
||||
} = {},
|
||||
): Promise<AssistantMessage> {
|
||||
const options = {
|
||||
@@ -280,7 +278,6 @@ async function run(
|
||||
timeoutMs: overrides.timeoutMs,
|
||||
headers: overrides.headers,
|
||||
onCompactionRejected: overrides.onCompactionRejected,
|
||||
onProviderAccepted: overrides.onProviderAccepted,
|
||||
};
|
||||
if (overrides.observations) {
|
||||
responsesPromptObserver.set(options, (observation) =>
|
||||
@@ -344,19 +341,6 @@ describe("native OpenAI Responses WebSocket client integration", () => {
|
||||
configureAiTransportHost(initialHost);
|
||||
});
|
||||
|
||||
it("reports WebSocket acceptance without fabricated HTTP metadata", async () => {
|
||||
transportState.responseBatches.push([message(completedEvent("resp_accepted", "ok"))]);
|
||||
const onProviderAccepted = vi.fn();
|
||||
|
||||
const result = await run(
|
||||
{ messages: [userMessage("hello", 1)], tools: [] },
|
||||
{ onProviderAccepted },
|
||||
);
|
||||
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(onProviderAccepted).toHaveBeenCalledWith({ kind: "provider_stream_opened" }, model);
|
||||
});
|
||||
|
||||
it("continues past provider-only output metadata with one socket and only new input", async () => {
|
||||
transportState.responseBatches.push(
|
||||
[message(completedEvent("resp_1", "first answer"))],
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { BaseOpenAIStreamOptions } from "../provider-options.js";
|
||||
/** Shared options, usage shape, cache identity, ordering, and stream scheduling for OpenAI APIs. */
|
||||
import { clampOpenAIPromptCacheKey } from "../providers/openai-prompt-cache.js";
|
||||
import { headersToRecord } from "../utils/headers.js";
|
||||
import { notifyProviderHttpMetadata, transportAbortError } from "./transport-stream-shared.js";
|
||||
import { transportAbortError } from "./transport-stream-shared.js";
|
||||
|
||||
export { sortPromptCacheToolsByName as sortTransportToolsByName } from "../utils/prompt-cache-stability.js";
|
||||
|
||||
@@ -230,22 +230,6 @@ export function createOpenAIResponseHook(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function createOpenAIProviderAcceptanceHook(
|
||||
options: Pick<BaseOpenAIStreamOptions, "onProviderAccepted" | "onResponse"> | undefined,
|
||||
response: Response,
|
||||
model: Model,
|
||||
): (() => void | Promise<void>) | undefined {
|
||||
if (!options?.onProviderAccepted) {
|
||||
return createOpenAIResponseHook(options?.onResponse, response, model);
|
||||
}
|
||||
return () =>
|
||||
notifyProviderHttpMetadata({
|
||||
options,
|
||||
response: { status: response.status, headers: headersToRecord(response.headers) },
|
||||
model,
|
||||
});
|
||||
}
|
||||
|
||||
type ModelStreamCooperativeScheduler = {
|
||||
afterEvent: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import type { Model, StreamOptions } from "@openclaw/llm-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
notifyProviderHttpResponse,
|
||||
notifyProviderStreamOpened,
|
||||
} from "./transport-stream-shared.js";
|
||||
|
||||
const model = { id: "acceptance-test", provider: "test" } as Model;
|
||||
|
||||
describe("notifyProviderHttpResponse", () => {
|
||||
it.each(["onProviderAccepted", "onResponse"] as const)(
|
||||
"cancels an unread response when %s fails",
|
||||
async (hookName) => {
|
||||
const hookError = new Error(`${hookName} failed`);
|
||||
const cancel = vi.fn();
|
||||
const response = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
cancel,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
const options: StreamOptions = {
|
||||
[hookName]: vi.fn(() => Promise.reject(hookError)),
|
||||
};
|
||||
|
||||
await expect(notifyProviderHttpResponse({ options, response, model })).rejects.toBe(
|
||||
hookError,
|
||||
);
|
||||
|
||||
expect(cancel).toHaveBeenCalledWith(hookError);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not wait for unread response cancellation after a callback fails", async () => {
|
||||
const hookError = new Error("acceptance failed");
|
||||
let markCancelStarted!: () => void;
|
||||
const cancelStarted = new Promise<void>((resolve) => {
|
||||
markCancelStarted = resolve;
|
||||
});
|
||||
const response = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
cancel() {
|
||||
markCancelStarted();
|
||||
return new Promise<void>(() => {});
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
const notification = notifyProviderHttpResponse({
|
||||
options: { onProviderAccepted: () => Promise.reject(hookError) },
|
||||
response,
|
||||
model,
|
||||
});
|
||||
|
||||
await cancelStarted;
|
||||
await expect(notification).rejects.toBe(hookError);
|
||||
});
|
||||
|
||||
it("reports a rejected HTTP response without marking it accepted", async () => {
|
||||
const onProviderAccepted = vi.fn();
|
||||
const onResponse = vi.fn();
|
||||
const options: StreamOptions = { onProviderAccepted, onResponse };
|
||||
const response = new Response("rejected", { status: 429 });
|
||||
|
||||
await notifyProviderHttpResponse({ options, response, model });
|
||||
|
||||
expect(onProviderAccepted).not.toHaveBeenCalled();
|
||||
expect(onResponse).toHaveBeenCalledWith(expect.objectContaining({ status: 429 }), model);
|
||||
});
|
||||
|
||||
it("does not resume response handling when a callback aborts its signal", async () => {
|
||||
const controller = new AbortController();
|
||||
const abortReason = Object.assign(new Error("operator canceled"), {
|
||||
code: "OPERATOR_CANCELLED",
|
||||
});
|
||||
const cancel = vi.fn();
|
||||
const response = new Response(new ReadableStream<Uint8Array>({ cancel }), { status: 200 });
|
||||
|
||||
await expect(
|
||||
notifyProviderHttpResponse({
|
||||
options: {
|
||||
signal: controller.signal,
|
||||
onProviderAccepted: () => controller.abort(abortReason),
|
||||
},
|
||||
response,
|
||||
model,
|
||||
}),
|
||||
).rejects.toBe(abortReason);
|
||||
expect(cancel).toHaveBeenCalledWith(abortReason);
|
||||
});
|
||||
|
||||
it("uses the option signal to abort a pending HTTP acceptance callback", async () => {
|
||||
const controller = new AbortController();
|
||||
const abortReason = Object.assign(new Error("operator canceled"), {
|
||||
code: "OPERATOR_CANCELLED",
|
||||
});
|
||||
const cancel = vi.fn();
|
||||
const response = new Response(new ReadableStream<Uint8Array>({ cancel }), { status: 200 });
|
||||
let markHookStarted!: () => void;
|
||||
const hookStarted = new Promise<void>((resolve) => {
|
||||
markHookStarted = resolve;
|
||||
});
|
||||
const options: StreamOptions = {
|
||||
signal: controller.signal,
|
||||
onProviderAccepted: () => {
|
||||
markHookStarted();
|
||||
return new Promise<void>(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
const notification = notifyProviderHttpResponse({ options, response, model });
|
||||
await hookStarted;
|
||||
controller.abort(abortReason);
|
||||
|
||||
await expect(notification).rejects.toBe(abortReason);
|
||||
expect(cancel).toHaveBeenCalledWith(abortReason);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifyProviderStreamOpened", () => {
|
||||
it("uses the option signal to abort a pending SDK acceptance callback", async () => {
|
||||
const controller = new AbortController();
|
||||
const abortReason = Object.assign(new Error("operator canceled"), {
|
||||
code: "OPERATOR_CANCELLED",
|
||||
});
|
||||
let markHookStarted!: () => void;
|
||||
const hookStarted = new Promise<void>((resolve) => {
|
||||
markHookStarted = resolve;
|
||||
});
|
||||
const options: StreamOptions = {
|
||||
signal: controller.signal,
|
||||
onProviderAccepted: () => {
|
||||
markHookStarted();
|
||||
return new Promise<void>(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
const notification = notifyProviderStreamOpened({ options, model });
|
||||
await hookStarted;
|
||||
controller.abort(abortReason);
|
||||
|
||||
await expect(notification).rejects.toBe(abortReason);
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,9 @@
|
||||
*
|
||||
* Sanitizes provider payloads, merges metadata, and formats streamed assistant events.
|
||||
*/
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Model,
|
||||
ProviderResponse,
|
||||
StreamOptions,
|
||||
Usage,
|
||||
} from "@openclaw/llm-core";
|
||||
import type { AssistantMessage, Usage } from "@openclaw/llm-core";
|
||||
import { asNonArrayRecord, asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { createAssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { headersToRecord } from "../utils/headers.js";
|
||||
import { projectProviderError, type ProviderErrorProjection } from "../utils/provider-error.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
|
||||
@@ -130,115 +123,6 @@ export function transportAbortError(signal?: AbortSignal): Error {
|
||||
: new Error("Request was aborted");
|
||||
}
|
||||
|
||||
type ProviderAcceptanceOptions = Pick<
|
||||
StreamOptions,
|
||||
"onProviderAccepted" | "onResponse" | "signal"
|
||||
>;
|
||||
|
||||
async function awaitProviderLifecycleCallback(
|
||||
callback: (() => void | Promise<void>) | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
throw transportAbortError(signal);
|
||||
}
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
const callbackPromise = Promise.resolve().then(callback);
|
||||
if (!signal) {
|
||||
await callbackPromise;
|
||||
return;
|
||||
}
|
||||
let onAbort: (() => void) | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
callbackPromise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
onAbort = () => reject(transportAbortError(signal));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (onAbort) {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw transportAbortError(signal);
|
||||
}
|
||||
}
|
||||
|
||||
/** Report observed HTTP metadata; rejected responses use only onResponse. */
|
||||
export async function notifyProviderHttpMetadata(params: {
|
||||
options?: ProviderAcceptanceOptions;
|
||||
response: ProviderResponse;
|
||||
model: Model;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
if (!params.options?.onProviderAccepted && !params.options?.onResponse) {
|
||||
return;
|
||||
}
|
||||
const { status, headers } = params.response;
|
||||
const signal = params.signal ?? params.options?.signal;
|
||||
const accepted = status >= 200 && status < 300;
|
||||
await awaitProviderLifecycleCallback(
|
||||
accepted && params.options.onProviderAccepted
|
||||
? () =>
|
||||
params.options?.onProviderAccepted?.(
|
||||
{ kind: "http_response", status, headers },
|
||||
params.model,
|
||||
)
|
||||
: undefined,
|
||||
signal,
|
||||
);
|
||||
await awaitProviderLifecycleCallback(
|
||||
params.options.onResponse
|
||||
? () => params.options?.onResponse?.({ status, headers }, params.model)
|
||||
: undefined,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
/** Report a real HTTP response before body consumption. */
|
||||
export async function notifyProviderHttpResponse(params: {
|
||||
options?: ProviderAcceptanceOptions;
|
||||
response: Response;
|
||||
model: Model;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await notifyProviderHttpMetadata({
|
||||
options: params.options,
|
||||
response: {
|
||||
status: params.response.status,
|
||||
headers: headersToRecord(params.response.headers),
|
||||
},
|
||||
model: params.model,
|
||||
signal: params.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
// Cancellation is best-effort cleanup; a stalled body must not retain the request owner
|
||||
// or delay the callback failure that made the body unreadable.
|
||||
void params.response.body?.cancel(error).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Report an accepted SDK stream when the SDK does not expose HTTP metadata. */
|
||||
export async function notifyProviderStreamOpened(params: {
|
||||
options?: Pick<StreamOptions, "onProviderAccepted" | "signal">;
|
||||
model: Model;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
await awaitProviderLifecycleCallback(
|
||||
params.options?.onProviderAccepted
|
||||
? () => params.options?.onProviderAccepted?.({ kind: "provider_stream_opened" }, params.model)
|
||||
: undefined,
|
||||
params.signal ?? params.options?.signal,
|
||||
);
|
||||
}
|
||||
|
||||
/** Run a provider-response hook before start/body consumption inside the first-event deadline. */
|
||||
export function withProviderResponseHook<T = never>(params: {
|
||||
stream?: AsyncIterable<T>;
|
||||
@@ -249,11 +133,27 @@ export function withProviderResponseHook<T = never>(params: {
|
||||
}): AsyncIterable<T> {
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
let onAbort: (() => void) | undefined;
|
||||
try {
|
||||
await awaitProviderLifecycleCallback(params.hook, params.signal);
|
||||
if (params.signal.aborted) {
|
||||
throw transportAbortError(params.signal);
|
||||
}
|
||||
if (params.hook) {
|
||||
await Promise.race([
|
||||
Promise.resolve().then(params.hook),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
onAbort = () => reject(transportAbortError(params.signal));
|
||||
params.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}),
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
params.abort(error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
} finally {
|
||||
if (onAbort) {
|
||||
params.signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
if (params.signal.aborted) {
|
||||
throw transportAbortError(params.signal);
|
||||
|
||||
@@ -64,15 +64,6 @@ export interface ProviderResponse {
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Evidence that a text provider accepted a request. */
|
||||
export type ProviderAcceptance =
|
||||
| {
|
||||
kind: "http_response";
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
| { kind: "provider_stream_opened" };
|
||||
|
||||
/** Request options shared by text streaming providers. */
|
||||
export interface StreamOptions {
|
||||
temperature?: number;
|
||||
@@ -122,13 +113,8 @@ export interface StreamOptions {
|
||||
*/
|
||||
onPayload?: (payload: unknown, model: Model) => MaybePromise<unknown>;
|
||||
/**
|
||||
* Optional callback invoked after the provider accepts the request and before
|
||||
* its body stream is consumed. HTTP metadata is included only when the transport sees it.
|
||||
*/
|
||||
onProviderAccepted?: (acceptance: ProviderAcceptance, model: Model) => void | Promise<void>;
|
||||
/**
|
||||
* Optional compatibility callback invoked after a transport receives a real
|
||||
* HTTP response and before its body stream is consumed.
|
||||
* Optional callback invoked after an HTTP response is received and before
|
||||
* its body stream is consumed.
|
||||
*/
|
||||
onResponse?: (response: ProviderResponse, model: Model) => void | Promise<void>;
|
||||
/**
|
||||
|
||||
@@ -136,10 +136,6 @@
|
||||
"types": "./dist/src/plugin-sdk/provider-http.d.ts",
|
||||
"default": "./src/provider-http.ts"
|
||||
},
|
||||
"./provider-lifecycle": {
|
||||
"types": "./dist/src/plugin-sdk/provider-lifecycle.d.ts",
|
||||
"default": "./src/provider-lifecycle.ts"
|
||||
},
|
||||
"./provider-model-shared": {
|
||||
"types": "./dist/src/plugin-sdk/provider-model-shared.d.ts",
|
||||
"default": "./src/provider-model-shared.ts"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/** Workspace facade for the public provider acceptance lifecycle API. */
|
||||
export * from "../../../src/plugin-sdk/provider-lifecycle.js";
|
||||
@@ -287,7 +287,6 @@
|
||||
"provider-entry",
|
||||
"provider-env-vars",
|
||||
"provider-http",
|
||||
"provider-lifecycle",
|
||||
"provider-binary-stream",
|
||||
"provider-model-types",
|
||||
"provider-model-shared",
|
||||
|
||||
@@ -195,8 +195,7 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
|
||||
// +1: canonical Computer Use wire contract and node-host provider seam.
|
||||
// -1: retire the deprecated messaging-targets subpath.
|
||||
// +2: bounded provider streams and read-only SecretRef resolution.
|
||||
// +1: supported provider request-acceptance lifecycle seam.
|
||||
147,
|
||||
146,
|
||||
env,
|
||||
),
|
||||
publicExports: readPluginSdkSurfaceBudgetEnv(
|
||||
@@ -301,8 +300,7 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
|
||||
// +1: account-scoped model catalog discovery for native agent harnesses.
|
||||
// +2: shared delegation policy (mode resolver + section builder) so harness
|
||||
// runtimes render the same guidance instead of diverging prompt copies.
|
||||
// +5: provider acceptance receipt/response types and three lifecycle helpers.
|
||||
4339,
|
||||
4334,
|
||||
env,
|
||||
),
|
||||
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
||||
@@ -387,8 +385,7 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
|
||||
// +1: canonical sensitive-URL redactor so plugin CLI errors never print URL userinfo.
|
||||
// +2: shared delegation policy (mode resolver + section builder) so harness
|
||||
// runtimes render the same guidance instead of diverging prompt copies.
|
||||
// +3: provider acceptance lifecycle helpers for HTTP and metadata-free streams.
|
||||
2580,
|
||||
2577,
|
||||
env,
|
||||
),
|
||||
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
|
||||
|
||||
@@ -172,7 +172,7 @@ describe("wrapStreamFnWithDiagnosticModelCallEvents lifecycle", () => {
|
||||
expect(events[0]?.status).toBeUndefined();
|
||||
});
|
||||
|
||||
it("records legacy response status without inferring provider acceptance", async () => {
|
||||
it("records provider response status and preserves the original response callback", async () => {
|
||||
const originalOnResponse = vi.fn(async () => undefined);
|
||||
const wrapped = wrapStreamFnWithDiagnosticModelCallEvents(
|
||||
((
|
||||
@@ -212,56 +212,9 @@ describe("wrapStreamFnWithDiagnosticModelCallEvents lifecycle", () => {
|
||||
type: "provider.request",
|
||||
ok: true,
|
||||
status: 200,
|
||||
attributes: {
|
||||
providerAccepted: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("records provider acceptance when an SDK hides HTTP metadata", async () => {
|
||||
const originalOnProviderAccepted = vi.fn(async () => undefined);
|
||||
const wrapped = wrapStreamFnWithDiagnosticModelCallEvents(
|
||||
((
|
||||
model: Parameters<StreamFn>[0],
|
||||
_context: Parameters<StreamFn>[1],
|
||||
options: Parameters<StreamFn>[2],
|
||||
) => {
|
||||
return options?.onProviderAccepted?.({ kind: "provider_stream_opened" }, model);
|
||||
}) as unknown as StreamFn,
|
||||
{
|
||||
runId: "run-timeline-accepted",
|
||||
provider: "google",
|
||||
model: "gemini-2.5-pro",
|
||||
api: "google-generative-ai",
|
||||
trace: createDiagnosticTraceContext(),
|
||||
nextCallId: () => "call-timeline-accepted",
|
||||
},
|
||||
);
|
||||
|
||||
const events = await collectProviderTimelineEvents(async () => {
|
||||
await wrapped(
|
||||
{ id: "gemini-2.5-pro" } as never,
|
||||
{} as never,
|
||||
{ onProviderAccepted: originalOnProviderAccepted } as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(originalOnProviderAccepted).toHaveBeenCalledWith(
|
||||
{ kind: "provider_stream_opened" },
|
||||
{ id: "gemini-2.5-pro" },
|
||||
);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
type: "provider.request",
|
||||
ok: true,
|
||||
attributes: {
|
||||
providerAccepted: true,
|
||||
providerAcceptanceKind: "provider_stream_opened",
|
||||
},
|
||||
});
|
||||
expect(events[0]?.status).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes Unicode-safe bounded attributes to the provider timeline JSONL", async () => {
|
||||
const modelPrefix = "m".repeat(255);
|
||||
const exactBoundary = "b".repeat(256);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ProviderAcceptance } from "@openclaw/llm-core";
|
||||
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { fireAndForgetBoundedHook } from "../../../hooks/fire-and-forget.js";
|
||||
@@ -86,7 +85,6 @@ export type ModelCallUsage = NonNullable<
|
||||
>;
|
||||
export type ModelCallObservationState = {
|
||||
requestPayloadBytes?: number;
|
||||
providerAcceptanceKind?: ProviderAcceptance["kind"];
|
||||
responseStatus?: number;
|
||||
responseStreamBytes: number;
|
||||
timeToFirstByteMs?: number;
|
||||
@@ -156,7 +154,6 @@ function emitProviderRequestTimelineEvent(
|
||||
durationMs: number,
|
||||
ok: boolean,
|
||||
responseStatus: number | undefined,
|
||||
providerAcceptanceKind: ModelCallObservationState["providerAcceptanceKind"],
|
||||
): void {
|
||||
const provider = boundedTimelineAttribute(eventBase.provider);
|
||||
const model = boundedTimelineAttribute(eventBase.model);
|
||||
@@ -177,8 +174,6 @@ function emitProviderRequestTimelineEvent(
|
||||
...(model ? { model } : {}),
|
||||
...(api ? { api } : {}),
|
||||
...(transport ? { transport } : {}),
|
||||
providerAccepted: providerAcceptanceKind !== undefined,
|
||||
...(providerAcceptanceKind ? { providerAcceptanceKind } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -296,7 +291,6 @@ function emitModelCallCompleted(
|
||||
durationMs,
|
||||
true,
|
||||
observer.state.responseStatus,
|
||||
observer.state.providerAcceptanceKind,
|
||||
);
|
||||
emitCoreModelRequestEndedDiagnosticEvent(
|
||||
{
|
||||
@@ -335,14 +329,7 @@ function emitModelCallError(
|
||||
const errorStatus = diagnosticHttpStatusCode(err);
|
||||
const responseStatus =
|
||||
observer.state.responseStatus ?? (errorStatus === undefined ? undefined : Number(errorStatus));
|
||||
emitProviderRequestTimelineEvent(
|
||||
eventBase,
|
||||
startedAt,
|
||||
durationMs,
|
||||
false,
|
||||
responseStatus,
|
||||
observer.state.providerAcceptanceKind,
|
||||
);
|
||||
emitProviderRequestTimelineEvent(eventBase, startedAt, durationMs, false, responseStatus);
|
||||
emitCoreModelRequestEndedDiagnosticEvent(
|
||||
{
|
||||
type: "model.call.error",
|
||||
@@ -373,7 +360,6 @@ function withDiagnosticRequestContext(
|
||||
): ModelCallStreamOptions {
|
||||
const traceparent = formatPropagatedDiagnosticTraceparent(trace);
|
||||
const originalOnPayload = options?.onPayload;
|
||||
const originalOnProviderAccepted = options?.onProviderAccepted;
|
||||
const originalOnResponse = options?.onResponse;
|
||||
const onPayload: NonNullable<ModelCallStreamOptions>["onPayload"] = (payload, model) => {
|
||||
if (!originalOnPayload) {
|
||||
@@ -390,16 +376,6 @@ function withDiagnosticRequestContext(
|
||||
observer.assignRequestPayloadBytes(result ?? payload);
|
||||
return result;
|
||||
};
|
||||
const onProviderAccepted: NonNullable<ModelCallStreamOptions>["onProviderAccepted"] = (
|
||||
acceptance,
|
||||
model,
|
||||
) => {
|
||||
observer.state.providerAcceptanceKind = acceptance.kind;
|
||||
if (acceptance.kind === "http_response") {
|
||||
observer.state.responseStatus = acceptance.status;
|
||||
}
|
||||
return originalOnProviderAccepted?.(acceptance, model);
|
||||
};
|
||||
const onResponse: NonNullable<ModelCallStreamOptions>["onResponse"] = (response, model) => {
|
||||
// Retrying providers can expose several responses; the terminal request status
|
||||
// is the latest response observed before the model call completes or fails.
|
||||
@@ -422,7 +398,6 @@ function withDiagnosticRequestContext(
|
||||
requestId: callId,
|
||||
...((options?.headers || traceparent) && { headers }),
|
||||
onPayload,
|
||||
onProviderAccepted,
|
||||
onResponse,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ export type {
|
||||
Message,
|
||||
Model,
|
||||
ModelThinkingLevel,
|
||||
ProviderAcceptance,
|
||||
ProviderResponse,
|
||||
ProviderStreamOptions,
|
||||
SimpleStreamOptions,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
/** Public provider request-acceptance lifecycle types and helpers. */
|
||||
export type { ProviderAcceptance, ProviderResponse } from "@openclaw/llm-core";
|
||||
export {
|
||||
notifyProviderHttpMetadata,
|
||||
notifyProviderHttpResponse,
|
||||
notifyProviderStreamOpened,
|
||||
} from "@openclaw/ai/transports";
|
||||
Reference in New Issue
Block a user