@gajae-code/ai 0.12.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/dist/types/model-thinking.d.ts +15 -0
- package/dist/types/providers/azure-openai-responses.d.ts +2 -0
- package/dist/types/providers/google-vertex.d.ts +2 -0
- package/dist/types/utils/fallback-transport.d.ts +2 -0
- package/dist/types/utils/http-inspector.d.ts +10 -0
- package/dist/types/utils/idle-iterator.d.ts +4 -0
- package/dist/types/utils/oauth/kimi.d.ts +2 -0
- package/dist/types/utils/oauth/perplexity.d.ts +2 -7
- package/package.json +2 -2
- package/src/model-thinking.ts +19 -0
- package/src/providers/amazon-bedrock.ts +5 -20
- package/src/providers/anthropic.ts +95 -44
- package/src/providers/azure-openai-responses.ts +28 -16
- package/src/providers/google-vertex.ts +35 -4
- package/src/providers/ollama.ts +32 -4
- package/src/providers/openai-codex-responses.ts +48 -31
- package/src/providers/openai-completions.ts +13 -12
- package/src/providers/openai-responses.ts +9 -11
- package/src/providers/register-builtins.ts +12 -2
- package/src/utils/fallback-transport.ts +17 -10
- package/src/utils/http-inspector.ts +47 -1
- package/src/utils/idle-iterator.ts +12 -3
- package/src/utils/oauth/kimi.ts +17 -2
- package/src/utils/oauth/perplexity.ts +21 -2
- package/src/utils/schema/adapt.ts +2 -2
- package/src/utils/tool-choice-capability.ts +2 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $credentialEnv, $
|
|
1
|
+
import { $credentialEnv, $pickCredentialEnv } from "@gajae-code/utils";
|
|
2
2
|
import type { Context, Model, StreamFunction } from "../types";
|
|
3
3
|
import type { AssistantMessageEventStream } from "../utils/event-stream";
|
|
4
4
|
import { getVertexAccessToken } from "./google-auth";
|
|
@@ -72,7 +72,7 @@ function resolveApiKey(options?: GoogleVertexOptions): string | undefined {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function resolveProject(options?: GoogleVertexOptions): string {
|
|
75
|
-
const project = options?.project || $
|
|
75
|
+
const project = options?.project || $pickCredentialEnv("GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT");
|
|
76
76
|
if (!project) {
|
|
77
77
|
throw new Error(
|
|
78
78
|
"Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options.",
|
|
@@ -84,10 +84,41 @@ function resolveProject(options?: GoogleVertexOptions): string {
|
|
|
84
84
|
function resolveEndpointHost(location: string): string {
|
|
85
85
|
return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Vertex location, from trusted environment sources only and constrained to a
|
|
89
|
+
* region label.
|
|
90
|
+
*
|
|
91
|
+
* The location is interpolated into the request **host**
|
|
92
|
+
* (`${location}-aiplatform.googleapis.com`) as well as the path, and the request
|
|
93
|
+
* carries `Authorization: Bearer <accessToken>`. A value containing `/`
|
|
94
|
+
* terminates the authority component, so `evil.example.com/` resolves to origin
|
|
95
|
+
* `https://evil.example.com` and the Google access token leaves Google entirely.
|
|
96
|
+
* `$env` merges the caller's `cwd/.env`, so this was reachable from repository
|
|
97
|
+
* content.
|
|
98
|
+
*
|
|
99
|
+
* Both halves are needed: trusted resolution keeps a repository from setting it,
|
|
100
|
+
* and the shape check keeps any source from turning a region into an authority.
|
|
101
|
+
*/
|
|
102
|
+
const VERTEX_LOCATION_RE = /^[a-z0-9-]+$/;
|
|
103
|
+
|
|
104
|
+
function assertVertexLocation(location: string): string {
|
|
105
|
+
if (!VERTEX_LOCATION_RE.test(location)) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Invalid Vertex AI location ${JSON.stringify(location)}. Expected a region label such as "us-central1" or "global".`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return location;
|
|
111
|
+
}
|
|
112
|
+
|
|
87
113
|
function resolveLocation(options?: GoogleVertexOptions): string {
|
|
88
|
-
const location = options?.location || $
|
|
114
|
+
const location = options?.location || $credentialEnv("GOOGLE_CLOUD_LOCATION");
|
|
89
115
|
if (!location) {
|
|
90
116
|
throw new Error("Vertex AI requires a location. Set GOOGLE_CLOUD_LOCATION or pass location in options.");
|
|
91
117
|
}
|
|
92
|
-
return location;
|
|
118
|
+
return assertVertexLocation(location);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Test seam: the Vertex location as resolved from options plus trusted env. */
|
|
122
|
+
export function resolveVertexLocationForTest(options?: GoogleVertexOptions): string {
|
|
123
|
+
return resolveLocation(options);
|
|
93
124
|
}
|
package/src/providers/ollama.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { normalizeSystemPrompts } from "../utils";
|
|
|
18
18
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
19
19
|
import { transportFailureFacts } from "../utils/fallback-transport";
|
|
20
20
|
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
|
|
21
|
-
import { parseStreamingJson } from "../utils/json-parse";
|
|
21
|
+
import { isCompleteJson, parseStreamingJson } from "../utils/json-parse";
|
|
22
22
|
import { resolveRetryBudget } from "../utils/retry-budget";
|
|
23
23
|
import { flattenToolRootCombinators, toolWireSchema } from "../utils/schema";
|
|
24
24
|
import {
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
markToolChoiceIncapability,
|
|
27
27
|
resolveToolChoice,
|
|
28
28
|
} from "../utils/tool-choice-capability";
|
|
29
|
+
import { flagTruncatedToolCalls } from "./openai-responses-shared";
|
|
29
30
|
import { transformMessages } from "./transform-messages";
|
|
30
31
|
|
|
31
32
|
export interface OllamaChatOptions extends StreamOptions {
|
|
@@ -357,8 +358,10 @@ function endToolCallBlock(stream: AssistantMessageEventStream, output: Assistant
|
|
|
357
358
|
return;
|
|
358
359
|
}
|
|
359
360
|
const toolCall = block as InternalToolCallBlock;
|
|
360
|
-
if (toolCall.partialJson) {
|
|
361
|
-
|
|
361
|
+
if (toolCall.partialJson !== undefined) {
|
|
362
|
+
if (toolCall.partialJson.trim()) {
|
|
363
|
+
toolCall.arguments = parseStreamingJson<Record<string, unknown>>(toolCall.partialJson);
|
|
364
|
+
}
|
|
362
365
|
delete toolCall.partialJson;
|
|
363
366
|
}
|
|
364
367
|
stream.push({ type: "toolcall_end", contentIndex: index, toolCall, partial: output });
|
|
@@ -393,6 +396,8 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
|
|
|
393
396
|
let activeThinkingIndex: number | undefined;
|
|
394
397
|
let activeTextIndex: number | undefined;
|
|
395
398
|
const activeToolIndices = new Set<number>();
|
|
399
|
+
const unverifiableArgumentToolCallIds = new Set<string>();
|
|
400
|
+
let sawTerminalChunk = false;
|
|
396
401
|
try {
|
|
397
402
|
const apiKey = options.apiKey || getEnvApiKey(model.provider);
|
|
398
403
|
if (!apiKey) {
|
|
@@ -537,6 +542,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
|
|
|
537
542
|
for (const call of chunk.message.tool_calls) {
|
|
538
543
|
const name = call.function?.name ?? "unknown_tool";
|
|
539
544
|
const rawArgs = call.function?.arguments;
|
|
545
|
+
const unverifiableArguments = typeof rawArgs !== "string";
|
|
540
546
|
const partialJson = typeof rawArgs === "string" ? rawArgs : JSON.stringify(rawArgs ?? {});
|
|
541
547
|
const toolCall: InternalToolCallBlock = {
|
|
542
548
|
type: "toolCall",
|
|
@@ -545,6 +551,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
|
|
|
545
551
|
arguments: parseStreamingJson<Record<string, unknown>>(partialJson),
|
|
546
552
|
partialJson,
|
|
547
553
|
};
|
|
554
|
+
if (unverifiableArguments) unverifiableArgumentToolCallIds.add(toolCall.id);
|
|
548
555
|
output.content.push(toolCall);
|
|
549
556
|
const index = output.content.length - 1;
|
|
550
557
|
activeToolIndices.add(index);
|
|
@@ -561,6 +568,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
|
|
|
561
568
|
}
|
|
562
569
|
}
|
|
563
570
|
if (chunk.done) {
|
|
571
|
+
sawTerminalChunk = true;
|
|
564
572
|
if (activeThinkingIndex !== undefined) {
|
|
565
573
|
endThinkingBlock(stream, output, activeThinkingIndex);
|
|
566
574
|
activeThinkingIndex = undefined;
|
|
@@ -569,16 +577,36 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
|
|
|
569
577
|
endTextBlock(stream, output, activeTextIndex);
|
|
570
578
|
activeTextIndex = undefined;
|
|
571
579
|
}
|
|
580
|
+
output.stopReason = mapDoneReason(chunk.done_reason, output);
|
|
581
|
+
// Ollama still owns every partialJson buffer here; use the helper's
|
|
582
|
+
// finalized-call branch before endToolCallBlock deletes those buffers.
|
|
583
|
+
// Non-string arguments have no raw completion evidence, so a length stop
|
|
584
|
+
// must fail closed rather than trusting normalized or re-serialized values.
|
|
585
|
+
flagTruncatedToolCalls(
|
|
586
|
+
output,
|
|
587
|
+
output.stopReason,
|
|
588
|
+
block => !unverifiableArgumentToolCallIds.has(block.id),
|
|
589
|
+
);
|
|
590
|
+
if (chunk.done_reason === undefined) {
|
|
591
|
+
for (const block of output.content) {
|
|
592
|
+
if (block.type !== "toolCall") continue;
|
|
593
|
+
const partialJson = (block as InternalToolCallBlock).partialJson;
|
|
594
|
+
if (partialJson !== undefined && !isCompleteJson(partialJson)) block.incompleteArguments = true;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
572
597
|
for (const index of activeToolIndices) {
|
|
573
598
|
endToolCallBlock(stream, output, index);
|
|
574
599
|
}
|
|
575
600
|
activeToolIndices.clear();
|
|
576
|
-
output.stopReason = mapDoneReason(chunk.done_reason, output);
|
|
577
601
|
output.usage.input = chunk.prompt_eval_count ?? 0;
|
|
578
602
|
output.usage.output = chunk.eval_count ?? 0;
|
|
579
603
|
output.usage.totalTokens = output.usage.input + output.usage.output;
|
|
604
|
+
break;
|
|
580
605
|
}
|
|
581
606
|
}
|
|
607
|
+
if (!sawTerminalChunk) {
|
|
608
|
+
throw new Error("Ollama stream ended before terminal done chunk");
|
|
609
|
+
}
|
|
582
610
|
output.duration = Date.now() - startTime;
|
|
583
611
|
if (firstTokenTime) {
|
|
584
612
|
output.ttft = firstTokenTime - startTime;
|
|
@@ -48,9 +48,13 @@ import {
|
|
|
48
48
|
sanitizeOpenAIResponsesHistoryItemsForReplay,
|
|
49
49
|
} from "../utils";
|
|
50
50
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
51
|
-
import { transportFailureFacts } from "../utils/fallback-transport";
|
|
51
|
+
import { STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE, transportFailureFacts } from "../utils/fallback-transport";
|
|
52
52
|
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
getOpenAIStreamIdleTimeoutMs,
|
|
55
|
+
getStreamFirstEventTimeoutMs,
|
|
56
|
+
iterateWithIdleTimeout,
|
|
57
|
+
} from "../utils/idle-iterator";
|
|
54
58
|
import { parseStreamingJson } from "../utils/json-parse";
|
|
55
59
|
import { resolveRetryBudget } from "../utils/retry-budget";
|
|
56
60
|
import {
|
|
@@ -103,7 +107,6 @@ const CODEX_MAX_RETRIES = 5;
|
|
|
103
107
|
const CODEX_RETRY_DELAY_MS = 500;
|
|
104
108
|
const CODEX_WEBSOCKET_CONNECT_TIMEOUT_MS = 10000;
|
|
105
109
|
const CODEX_WEBSOCKET_IDLE_TIMEOUT_MS = 300000;
|
|
106
|
-
const CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS = 15000;
|
|
107
110
|
const CODEX_WEBSOCKET_RETRY_BUDGET = CODEX_MAX_RETRIES;
|
|
108
111
|
const CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX = "Codex websocket transport error";
|
|
109
112
|
const CODEX_PREVIOUS_RESPONSE_STALE_CODES = new Set(["previous_response_not_found", "codex_previous_response_stale"]);
|
|
@@ -274,6 +277,7 @@ async function retryCodexInitialTransportWithoutToolChoice(
|
|
|
274
277
|
|
|
275
278
|
interface CodexRequestSetup {
|
|
276
279
|
requestSignal: AbortSignal;
|
|
280
|
+
firstEventTimeoutMs: number | undefined;
|
|
277
281
|
wrapCodexSseStream: (source: AsyncGenerator<Record<string, unknown>>) => AsyncGenerator<Record<string, unknown>>;
|
|
278
282
|
requestAbortController: AbortController;
|
|
279
283
|
}
|
|
@@ -355,16 +359,6 @@ function getCodexWebSocketIdleTimeoutMs(overrideMs?: number): number {
|
|
|
355
359
|
);
|
|
356
360
|
}
|
|
357
361
|
|
|
358
|
-
function getCodexWebSocketFirstEventTimeoutMs(idleTimeoutMs: number, overrideMs?: number): number {
|
|
359
|
-
return (
|
|
360
|
-
overrideMs ??
|
|
361
|
-
parseCodexPositiveInteger(
|
|
362
|
-
$env.PI_CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS,
|
|
363
|
-
Math.min(CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS, idleTimeoutMs),
|
|
364
|
-
)
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
362
|
function createCodexProviderSessionState(): CodexProviderSessionState {
|
|
369
363
|
const state: CodexProviderSessionState = {
|
|
370
364
|
webSocketSessions: new Map(),
|
|
@@ -391,8 +385,12 @@ function getCodexProviderSessionState(
|
|
|
391
385
|
return created;
|
|
392
386
|
}
|
|
393
387
|
|
|
394
|
-
function createCodexWebSocketTransportError(message: string): Error {
|
|
395
|
-
|
|
388
|
+
function createCodexWebSocketTransportError(message: string, providerCode?: string): Error & { providerCode?: string } {
|
|
389
|
+
const error = new Error(`${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: ${message}`) as Error & {
|
|
390
|
+
providerCode?: string;
|
|
391
|
+
};
|
|
392
|
+
error.providerCode = providerCode;
|
|
393
|
+
return error;
|
|
396
394
|
}
|
|
397
395
|
|
|
398
396
|
function isCodexWebSocketFatalError(error: Error): boolean {
|
|
@@ -405,6 +403,13 @@ function isCodexWebSocketTransportError(error: unknown): boolean {
|
|
|
405
403
|
return error.message.startsWith(CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX);
|
|
406
404
|
}
|
|
407
405
|
|
|
406
|
+
function isCodexFirstEventTimeout(error: unknown): boolean {
|
|
407
|
+
return (
|
|
408
|
+
error instanceof Error &&
|
|
409
|
+
(error as { providerCode?: unknown }).providerCode === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
408
413
|
function isCodexWebSocketRetryableStreamError(error: unknown): boolean {
|
|
409
414
|
if (!(error instanceof Error) || !isCodexWebSocketTransportError(error)) return false;
|
|
410
415
|
const message = error.message.toLowerCase();
|
|
@@ -607,17 +612,22 @@ function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): C
|
|
|
607
612
|
const requestSignal = options?.signal
|
|
608
613
|
? AbortSignal.any([options.signal, requestAbortController.signal])
|
|
609
614
|
: requestAbortController.signal;
|
|
615
|
+
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
|
|
616
|
+
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
|
|
610
617
|
const wrapCodexSseStream = (
|
|
611
618
|
source: AsyncGenerator<Record<string, unknown>>,
|
|
612
619
|
): AsyncGenerator<Record<string, unknown>> =>
|
|
613
620
|
iterateWithIdleTimeout(source, {
|
|
614
|
-
idleTimeoutMs
|
|
621
|
+
idleTimeoutMs,
|
|
622
|
+
firstItemTimeoutMs: firstEventTimeoutMs,
|
|
623
|
+
firstItemErrorMessage: "OpenAI Codex SSE stream timed out while waiting for the first event",
|
|
615
624
|
errorMessage: "OpenAI Codex SSE stream stalled while waiting for the next event",
|
|
616
625
|
onIdle: () => requestAbortController.abort(),
|
|
626
|
+
onFirstItemTimeout: () => requestAbortController.abort(),
|
|
617
627
|
abortSignal: options?.signal,
|
|
618
628
|
isProgressItem: isCodexStreamProgressEvent,
|
|
619
629
|
});
|
|
620
|
-
return { requestAbortController, requestSignal, wrapCodexSseStream };
|
|
630
|
+
return { requestAbortController, requestSignal, firstEventTimeoutMs, wrapCodexSseStream };
|
|
621
631
|
}
|
|
622
632
|
|
|
623
633
|
async function buildCodexRequestContext(
|
|
@@ -838,6 +848,7 @@ async function openCodexWebSocketTransport(
|
|
|
838
848
|
websocketState,
|
|
839
849
|
requestSetup.requestSignal,
|
|
840
850
|
options,
|
|
851
|
+
requestSetup.firstEventTimeoutMs,
|
|
841
852
|
);
|
|
842
853
|
return { eventStream, requestBodyForState, transport: "websocket" };
|
|
843
854
|
}
|
|
@@ -1478,7 +1489,7 @@ async function recoverCodexStreamError(
|
|
|
1478
1489
|
runtime: CodexStreamRuntime,
|
|
1479
1490
|
error: unknown,
|
|
1480
1491
|
): Promise<boolean> {
|
|
1481
|
-
if (
|
|
1492
|
+
if (isCodexFirstEventTimeout(error)) return false;
|
|
1482
1493
|
if (await tryRetryWithoutForcedToolChoice(context, runtime, error)) {
|
|
1483
1494
|
return true;
|
|
1484
1495
|
}
|
|
@@ -2196,7 +2207,6 @@ function headersToRecord(headers: Headers): Record<string, string> {
|
|
|
2196
2207
|
|
|
2197
2208
|
interface CodexWebSocketConnectionOptions {
|
|
2198
2209
|
idleTimeoutMs: number;
|
|
2199
|
-
firstEventTimeoutMs: number;
|
|
2200
2210
|
onHandshakeHeaders?: (headers: Headers) => void;
|
|
2201
2211
|
}
|
|
2202
2212
|
|
|
@@ -2204,7 +2214,6 @@ class CodexWebSocketConnection {
|
|
|
2204
2214
|
#url: string;
|
|
2205
2215
|
#headers: Record<string, string>;
|
|
2206
2216
|
#idleTimeoutMs: number;
|
|
2207
|
-
#firstEventTimeoutMs: number;
|
|
2208
2217
|
#onHandshakeHeaders?: (headers: Headers) => void;
|
|
2209
2218
|
#socket: Bun.WebSocket | null = null;
|
|
2210
2219
|
#queue: Array<Record<string, unknown> | Error | null> = [];
|
|
@@ -2216,7 +2225,6 @@ class CodexWebSocketConnection {
|
|
|
2216
2225
|
this.#url = url;
|
|
2217
2226
|
this.#headers = headers;
|
|
2218
2227
|
this.#idleTimeoutMs = options.idleTimeoutMs;
|
|
2219
|
-
this.#firstEventTimeoutMs = options.firstEventTimeoutMs;
|
|
2220
2228
|
this.#onHandshakeHeaders = options.onHandshakeHeaders;
|
|
2221
2229
|
}
|
|
2222
2230
|
|
|
@@ -2346,6 +2354,7 @@ class CodexWebSocketConnection {
|
|
|
2346
2354
|
async *streamRequest(
|
|
2347
2355
|
request: Record<string, unknown>,
|
|
2348
2356
|
signal?: AbortSignal,
|
|
2357
|
+
firstEventTimeoutMs?: number,
|
|
2349
2358
|
): AsyncGenerator<Record<string, unknown>> {
|
|
2350
2359
|
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
|
|
2351
2360
|
throw createCodexWebSocketTransportError("websocket connection is unavailable");
|
|
@@ -2368,11 +2377,11 @@ class CodexWebSocketConnection {
|
|
|
2368
2377
|
|
|
2369
2378
|
try {
|
|
2370
2379
|
this.#socket.send(JSON.stringify(request));
|
|
2371
|
-
let
|
|
2380
|
+
let sawFirstProgress = false;
|
|
2372
2381
|
let lastProgressAt = Date.now();
|
|
2373
2382
|
while (true) {
|
|
2374
|
-
let timeoutMs =
|
|
2375
|
-
if (
|
|
2383
|
+
let timeoutMs = firstEventTimeoutMs;
|
|
2384
|
+
if (sawFirstProgress) {
|
|
2376
2385
|
timeoutMs = this.#idleTimeoutMs - (Date.now() - lastProgressAt);
|
|
2377
2386
|
if (timeoutMs <= 0) {
|
|
2378
2387
|
throw createCodexWebSocketTransportError("idle timeout waiting for websocket");
|
|
@@ -2380,7 +2389,8 @@ class CodexWebSocketConnection {
|
|
|
2380
2389
|
}
|
|
2381
2390
|
const next = await this.#nextMessage(
|
|
2382
2391
|
timeoutMs,
|
|
2383
|
-
|
|
2392
|
+
sawFirstProgress ? "idle timeout waiting for websocket" : "timeout waiting for first websocket event",
|
|
2393
|
+
sawFirstProgress ? undefined : STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE,
|
|
2384
2394
|
);
|
|
2385
2395
|
if (next instanceof Error) {
|
|
2386
2396
|
throw next;
|
|
@@ -2388,8 +2398,8 @@ class CodexWebSocketConnection {
|
|
|
2388
2398
|
if (next === null) {
|
|
2389
2399
|
throw createCodexWebSocketTransportError("websocket closed before response completion");
|
|
2390
2400
|
}
|
|
2391
|
-
sawFirstEvent = true;
|
|
2392
2401
|
if (isCodexStreamProgressEvent(next)) {
|
|
2402
|
+
sawFirstProgress = true;
|
|
2393
2403
|
lastProgressAt = Date.now();
|
|
2394
2404
|
}
|
|
2395
2405
|
yield next;
|
|
@@ -2425,13 +2435,17 @@ class CodexWebSocketConnection {
|
|
|
2425
2435
|
if (waiter) waiter();
|
|
2426
2436
|
}
|
|
2427
2437
|
|
|
2428
|
-
async #nextMessage(
|
|
2438
|
+
async #nextMessage(
|
|
2439
|
+
timeoutMs: number | undefined,
|
|
2440
|
+
timeoutReason: string,
|
|
2441
|
+
providerCode?: string,
|
|
2442
|
+
): Promise<Record<string, unknown> | Error | null> {
|
|
2429
2443
|
while (this.#queue.length === 0) {
|
|
2430
2444
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
2431
2445
|
this.#waiters.push(resolve);
|
|
2432
2446
|
let timedOut = false;
|
|
2433
2447
|
let timeout: NodeJS.Timeout | undefined;
|
|
2434
|
-
if (timeoutMs > 0) {
|
|
2448
|
+
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
2435
2449
|
timeout = setTimeout(() => {
|
|
2436
2450
|
timedOut = true;
|
|
2437
2451
|
const waiterIndex = this.#waiters.indexOf(resolve);
|
|
@@ -2444,7 +2458,10 @@ class CodexWebSocketConnection {
|
|
|
2444
2458
|
await promise;
|
|
2445
2459
|
if (timeout) clearTimeout(timeout);
|
|
2446
2460
|
if (timedOut && this.#queue.length === 0) {
|
|
2447
|
-
|
|
2461
|
+
if (providerCode === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE) {
|
|
2462
|
+
this.close("first-event-timeout");
|
|
2463
|
+
}
|
|
2464
|
+
return createCodexWebSocketTransportError(timeoutReason, providerCode);
|
|
2448
2465
|
}
|
|
2449
2466
|
}
|
|
2450
2467
|
return this.#queue.shift() ?? null;
|
|
@@ -2473,7 +2490,6 @@ async function getOrCreateCodexWebSocketConnection(
|
|
|
2473
2490
|
const idleTimeoutMs = getCodexWebSocketIdleTimeoutMs(options?.streamIdleTimeoutMs);
|
|
2474
2491
|
state.connection = new CodexWebSocketConnection(url, headerRecord, {
|
|
2475
2492
|
idleTimeoutMs,
|
|
2476
|
-
firstEventTimeoutMs: getCodexWebSocketFirstEventTimeoutMs(idleTimeoutMs, options?.streamFirstEventTimeoutMs),
|
|
2477
2493
|
onHandshakeHeaders: handshakeHeaders => {
|
|
2478
2494
|
updateCodexSessionMetadataFromHeaders(state, handshakeHeaders);
|
|
2479
2495
|
},
|
|
@@ -2544,9 +2560,10 @@ async function openCodexWebSocketEventStream(
|
|
|
2544
2560
|
state: CodexWebSocketSessionState,
|
|
2545
2561
|
signal?: AbortSignal,
|
|
2546
2562
|
options?: Pick<OpenAICodexResponsesOptions, "streamFirstEventTimeoutMs" | "streamIdleTimeoutMs">,
|
|
2563
|
+
firstEventTimeoutMs?: number,
|
|
2547
2564
|
): Promise<AsyncGenerator<Record<string, unknown>>> {
|
|
2548
2565
|
const connection = await getOrCreateCodexWebSocketConnection(state, url, headers, signal, options);
|
|
2549
|
-
return connection.streamRequest(request, signal);
|
|
2566
|
+
return connection.streamRequest(request, signal, firstEventTimeoutMs);
|
|
2550
2567
|
}
|
|
2551
2568
|
|
|
2552
2569
|
function createCodexHeaders(
|
|
@@ -47,7 +47,6 @@ import {
|
|
|
47
47
|
rewriteCopilotError,
|
|
48
48
|
} from "../utils/http-inspector";
|
|
49
49
|
import {
|
|
50
|
-
createWatchdog,
|
|
51
50
|
getOpenAIStreamIdleTimeoutMs,
|
|
52
51
|
getProviderFirstEventTimeoutFallbackMs,
|
|
53
52
|
getStreamFirstEventTimeoutMs,
|
|
@@ -457,7 +456,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
457
456
|
const output: AssistantMessage = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);
|
|
458
457
|
let rawRequestDump: RawHttpRequestDump | undefined;
|
|
459
458
|
const abortTracker = createAbortSourceTracker(options?.signal);
|
|
460
|
-
const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE);
|
|
461
459
|
const { requestAbortController, requestSignal } = abortTracker;
|
|
462
460
|
|
|
463
461
|
try {
|
|
@@ -579,10 +577,8 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
579
577
|
model.provider === "alibaba-token-plan"
|
|
580
578
|
? ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS
|
|
581
579
|
: getProviderFirstEventTimeoutFallbackMs(model.provider);
|
|
582
|
-
const
|
|
583
|
-
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs)
|
|
584
|
-
() => abortTracker.abortLocally(firstEventTimeoutAbortError),
|
|
585
|
-
);
|
|
580
|
+
const firstEventTimeoutMs =
|
|
581
|
+
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs);
|
|
586
582
|
if (premiumRequestsTotal !== undefined) {
|
|
587
583
|
output.usage.premiumRequests = premiumRequestsTotal;
|
|
588
584
|
}
|
|
@@ -769,10 +765,12 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
769
765
|
};
|
|
770
766
|
|
|
771
767
|
for await (const chunk of iterateWithIdleTimeout(openaiStream, {
|
|
772
|
-
|
|
768
|
+
firstItemTimeoutMs: firstEventTimeoutMs,
|
|
769
|
+
firstItemErrorMessage: OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE,
|
|
773
770
|
idleTimeoutMs,
|
|
774
771
|
errorMessage: "OpenAI completions stream stalled while waiting for the next event",
|
|
775
772
|
onIdle: () => requestAbortController.abort(),
|
|
773
|
+
onFirstItemTimeout: () => requestAbortController.abort(),
|
|
776
774
|
abortSignal: options?.signal,
|
|
777
775
|
isProgressItem: isOpenAICompletionsProgressChunk,
|
|
778
776
|
})) {
|
|
@@ -983,14 +981,17 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
983
981
|
stream.end();
|
|
984
982
|
} catch (error) {
|
|
985
983
|
for (const block of output.content) delete (block as any).index;
|
|
986
|
-
const
|
|
984
|
+
const localAbortReason = abortTracker.getLocalAbortReason();
|
|
987
985
|
const capturedErrorResponse = getCapturedErrorResponse?.();
|
|
988
986
|
output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
|
|
989
|
-
output.errorStatus =
|
|
990
|
-
|
|
987
|
+
output.errorStatus =
|
|
988
|
+
extractHttpStatusFromError(localAbortReason ?? error) ??
|
|
989
|
+
(localAbortReason ? undefined : capturedErrorResponse?.status);
|
|
990
|
+
output.transportFailure = localAbortReason
|
|
991
|
+
? transportFailureFacts(localAbortReason)
|
|
992
|
+
: transportFailureFacts(error, capturedErrorResponse);
|
|
991
993
|
output.errorMessage =
|
|
992
|
-
|
|
993
|
-
(await finalizeErrorMessage(error, rawRequestDump, capturedErrorResponse));
|
|
994
|
+
localAbortReason?.message ?? (await finalizeErrorMessage(error, rawRequestDump, capturedErrorResponse));
|
|
994
995
|
// Some providers via OpenRouter include extra details here.
|
|
995
996
|
const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw;
|
|
996
997
|
if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
|
|
@@ -37,7 +37,6 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
|
37
37
|
import { transportFailureFacts } from "../utils/fallback-transport";
|
|
38
38
|
import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
|
|
39
39
|
import {
|
|
40
|
-
createWatchdog,
|
|
41
40
|
getOpenAIStreamIdleTimeoutMs,
|
|
42
41
|
getStreamFirstEventTimeoutMs,
|
|
43
42
|
iterateWithIdleTimeout,
|
|
@@ -265,7 +264,6 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
265
264
|
);
|
|
266
265
|
let rawRequestDump: RawHttpRequestDump | undefined;
|
|
267
266
|
const abortTracker = createAbortSourceTracker(options?.signal);
|
|
268
|
-
const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
|
|
269
267
|
const { requestAbortController, requestSignal } = abortTracker;
|
|
270
268
|
|
|
271
269
|
try {
|
|
@@ -337,10 +335,8 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
337
335
|
});
|
|
338
336
|
const firstEventFallbackMs =
|
|
339
337
|
model.provider === "alibaba-token-plan" ? ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS : undefined;
|
|
340
|
-
const
|
|
341
|
-
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs)
|
|
342
|
-
() => abortTracker.abortLocally(firstEventTimeoutAbortError),
|
|
343
|
-
);
|
|
338
|
+
const firstEventTimeoutMs =
|
|
339
|
+
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs);
|
|
344
340
|
if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
|
|
345
341
|
stream.push({ type: "start", partial: output });
|
|
346
342
|
|
|
@@ -348,9 +344,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
348
344
|
await processResponsesStream(
|
|
349
345
|
iterateWithIdleTimeout(openaiStream, {
|
|
350
346
|
idleTimeoutMs,
|
|
351
|
-
|
|
347
|
+
firstItemTimeoutMs: firstEventTimeoutMs,
|
|
348
|
+
firstItemErrorMessage: OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
|
|
352
349
|
errorMessage: "OpenAI responses stream stalled while waiting for the next event",
|
|
353
350
|
onIdle: () => requestAbortController.abort(),
|
|
351
|
+
onFirstItemTimeout: () => requestAbortController.abort(),
|
|
354
352
|
abortSignal: options?.signal,
|
|
355
353
|
isProgressItem: isOpenAIResponsesProgressEvent,
|
|
356
354
|
}),
|
|
@@ -389,11 +387,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
389
387
|
stream.end();
|
|
390
388
|
} catch (error) {
|
|
391
389
|
for (const block of output.content) delete (block as { index?: number }).index;
|
|
392
|
-
const
|
|
390
|
+
const localAbortReason = abortTracker.getLocalAbortReason();
|
|
393
391
|
output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
|
|
394
|
-
output.errorStatus = extractHttpStatusFromError(error);
|
|
395
|
-
output.transportFailure = transportFailureFacts(error);
|
|
396
|
-
output.errorMessage =
|
|
392
|
+
output.errorStatus = extractHttpStatusFromError(localAbortReason ?? error);
|
|
393
|
+
output.transportFailure = transportFailureFacts(localAbortReason ?? error);
|
|
394
|
+
output.errorMessage = localAbortReason?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
|
|
397
395
|
output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
|
|
398
396
|
// Explicitly mark the poisoned-history rejection so the shared
|
|
399
397
|
// `invalid_prompt` contract is present even when the SDK error surfaces
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* lazy wrappers below), so this file IS the main streaming path's provider
|
|
11
11
|
* loader: heavy SDKs stay out of the CLI startup parse graph.
|
|
12
12
|
*/
|
|
13
|
+
|
|
13
14
|
import type {
|
|
14
15
|
Api,
|
|
15
16
|
AssistantMessage,
|
|
@@ -21,7 +22,13 @@ import type {
|
|
|
21
22
|
} from "../types";
|
|
22
23
|
import { type AbortSourceTracker, createAbortSourceTracker } from "../utils/abort";
|
|
23
24
|
import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-stream";
|
|
24
|
-
import {
|
|
25
|
+
import { transportFailureFacts } from "../utils/fallback-transport";
|
|
26
|
+
import {
|
|
27
|
+
FirstEventTimeoutError,
|
|
28
|
+
getStreamFirstEventTimeoutMs,
|
|
29
|
+
getStreamIdleTimeoutMs,
|
|
30
|
+
iterateWithIdleTimeout,
|
|
31
|
+
} from "../utils/idle-iterator";
|
|
25
32
|
import type { BedrockOptions } from "./amazon-bedrock";
|
|
26
33
|
import type { AnthropicOptions } from "./anthropic";
|
|
27
34
|
import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses";
|
|
@@ -229,7 +236,8 @@ function forwardStream<TApi extends Api>(
|
|
|
229
236
|
errorMessage: LAZY_STREAM_IDLE_TIMEOUT_ERROR,
|
|
230
237
|
firstItemErrorMessage: LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR,
|
|
231
238
|
onIdle: () => abortTracker.abortLocally(new Error(LAZY_STREAM_IDLE_TIMEOUT_ERROR)),
|
|
232
|
-
onFirstItemTimeout: () =>
|
|
239
|
+
onFirstItemTimeout: () =>
|
|
240
|
+
abortTracker.abortLocally(new FirstEventTimeoutError(LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR)),
|
|
233
241
|
abortSignal: options.signal,
|
|
234
242
|
// The synthetic `start` event is yielded immediately by every provider before
|
|
235
243
|
// the upstream model has emitted any tokens. Treating it as the first "real"
|
|
@@ -261,6 +269,7 @@ function createLazyLoadErrorMessage<TApi extends Api>(
|
|
|
261
269
|
error: unknown,
|
|
262
270
|
stopReason: Extract<AssistantMessage["stopReason"], "aborted" | "error"> = "error",
|
|
263
271
|
): AssistantMessage {
|
|
272
|
+
const transportFailure = transportFailureFacts(error);
|
|
264
273
|
return {
|
|
265
274
|
role: "assistant",
|
|
266
275
|
content: [],
|
|
@@ -278,6 +287,7 @@ function createLazyLoadErrorMessage<TApi extends Api>(
|
|
|
278
287
|
stopReason,
|
|
279
288
|
errorMessage:
|
|
280
289
|
stopReason === "aborted" ? "Request was aborted" : error instanceof Error ? error.message : String(error),
|
|
290
|
+
...(transportFailure ? { transportFailure } : {}),
|
|
281
291
|
timestamp: Date.now(),
|
|
282
292
|
};
|
|
283
293
|
}
|
|
@@ -5,6 +5,9 @@ export interface FallbackTrigger {
|
|
|
5
5
|
retryAfterMs?: number;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/** Stable code for streams that time out before producing semantic progress. */
|
|
9
|
+
export const STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE = "stream_first_event_timeout";
|
|
10
|
+
|
|
8
11
|
export type TransportHeaders = Headers | Record<string, string | undefined>;
|
|
9
12
|
|
|
10
13
|
/**
|
|
@@ -185,7 +188,8 @@ export function transportFailureFacts(
|
|
|
185
188
|
!isQuotaCode(normalizedCode) &&
|
|
186
189
|
!isAuthCode(normalizedCode) &&
|
|
187
190
|
!isRateLimitCode(normalizedCode) &&
|
|
188
|
-
!isContextOverflowCode(normalizedCode)
|
|
191
|
+
!isContextOverflowCode(normalizedCode) &&
|
|
192
|
+
normalizedCode !== STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE
|
|
189
193
|
) {
|
|
190
194
|
return undefined;
|
|
191
195
|
}
|
|
@@ -256,14 +260,17 @@ export function classifyFallbackTrigger(
|
|
|
256
260
|
parseRetryAfterMilliseconds(headers?.get("retry-after-ms") ?? null) ??
|
|
257
261
|
parseRetryAfterSeconds(headers?.get("retry-after") ?? null);
|
|
258
262
|
const code = (facts.openaiErrorCode ?? facts.anthropicErrorType ?? facts.providerCode)?.toLowerCase();
|
|
259
|
-
const triggerClass: FallbackTriggerClass =
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
263
|
+
const triggerClass: FallbackTriggerClass =
|
|
264
|
+
code === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE
|
|
265
|
+
? "server"
|
|
266
|
+
: isQuotaCode(code)
|
|
267
|
+
? "quota"
|
|
268
|
+
: facts.status === 401 || facts.status === 403 || isAuthCode(code)
|
|
269
|
+
? "auth"
|
|
270
|
+
: facts.status === 429 || isRateLimitCode(code)
|
|
271
|
+
? "rate_limit"
|
|
272
|
+
: facts.status !== undefined && facts.status >= 500 && facts.status <= 599
|
|
273
|
+
? "server"
|
|
274
|
+
: "other";
|
|
268
275
|
return retryAfterMs === undefined ? { class: triggerClass } : { class: triggerClass, retryAfterMs };
|
|
269
276
|
}
|