@gajae-code/ai 0.11.11 → 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 +42 -0
- package/README.md +3 -0
- package/dist/types/model-thinking.d.ts +15 -0
- package/dist/types/provider-models/openai-compat.d.ts +7 -0
- package/dist/types/providers/anthropic.d.ts +2 -1
- package/dist/types/providers/azure-openai-responses.d.ts +8 -1
- package/dist/types/providers/google-auth.d.ts +2 -0
- package/dist/types/providers/google-gemini-headers.d.ts +1 -1
- package/dist/types/providers/google-vertex.d.ts +4 -0
- package/dist/types/providers/openai-codex-responses.d.ts +4 -0
- package/dist/types/providers/openai-completions.d.ts +2 -0
- package/dist/types/providers/openai-responses.d.ts +2 -0
- package/dist/types/types.d.ts +1 -1
- package/dist/types/usage/grok-cli.d.ts +3 -1
- package/dist/types/usage/kimi.d.ts +2 -0
- package/dist/types/utils/anthropic-auth.d.ts +8 -0
- package/dist/types/utils/fallback-transport.d.ts +2 -0
- package/dist/types/utils/foundry.d.ts +10 -0
- package/dist/types/utils/http-inspector.d.ts +23 -0
- package/dist/types/utils/idle-iterator.d.ts +4 -0
- package/dist/types/utils/oauth/bizrouter.d.ts +1 -0
- package/dist/types/utils/oauth/kimi.d.ts +2 -0
- package/dist/types/utils/oauth/perplexity.d.ts +2 -7
- package/dist/types/utils/oauth/types.d.ts +1 -1
- package/package.json +2 -2
- package/src/auth-storage.ts +6 -0
- package/src/cli.ts +1 -0
- package/src/model-thinking.ts +19 -0
- package/src/models.json +72 -0
- package/src/provider-models/descriptors.ts +7 -0
- package/src/provider-models/openai-compat.ts +67 -6
- package/src/providers/amazon-bedrock.ts +5 -20
- package/src/providers/anthropic.ts +103 -48
- package/src/providers/azure-openai-responses.ts +44 -19
- package/src/providers/google-auth.ts +13 -2
- package/src/providers/google-gemini-headers.ts +1 -1
- package/src/providers/google-vertex.ts +41 -5
- package/src/providers/ollama.ts +32 -4
- package/src/providers/openai-codex-responses.ts +97 -38
- package/src/providers/openai-completions.ts +25 -14
- package/src/providers/openai-responses.ts +22 -20
- package/src/providers/register-builtins.ts +12 -2
- package/src/stream.ts +1 -0
- package/src/types.ts +1 -0
- package/src/usage/claude.ts +2 -1
- package/src/usage/grok-cli.ts +12 -1
- package/src/usage/kimi.ts +16 -2
- package/src/utils/anthropic-auth.ts +11 -3
- package/src/utils/fallback-transport.ts +17 -10
- package/src/utils/foundry.ts +12 -2
- package/src/utils/http-inspector.ts +124 -1
- package/src/utils/idle-iterator.ts +12 -3
- package/src/utils/oauth/bizrouter.ts +15 -0
- package/src/utils/oauth/index.ts +6 -0
- package/src/utils/oauth/kimi.ts +17 -2
- package/src/utils/oauth/perplexity.ts +21 -2
- package/src/utils/oauth/types.ts +1 -0
- package/src/utils/schema/adapt.ts +2 -2
- package/src/utils/tool-choice-capability.ts +2 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $
|
|
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";
|
|
@@ -58,16 +58,21 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
|
|
|
58
58
|
},
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
/** Test seam: the Vertex API key as resolved from options plus trusted env. */
|
|
62
|
+
export function resolveVertexApiKeyForTest(options?: GoogleVertexOptions): string | undefined {
|
|
63
|
+
return resolveApiKey(options);
|
|
64
|
+
}
|
|
65
|
+
|
|
61
66
|
function resolveApiKey(options?: GoogleVertexOptions): string | undefined {
|
|
62
67
|
// options.apiKey may contain sentinel values like "<authenticated>" or "N/A"
|
|
63
68
|
// leaked from the agent loop — only use it if it looks like a real API key.
|
|
64
69
|
const optKey = options?.apiKey;
|
|
65
70
|
const realKey = optKey && !optKey.startsWith("<") && optKey !== "N/A" ? optKey : undefined;
|
|
66
|
-
return realKey || $
|
|
71
|
+
return realKey || $credentialEnv("GOOGLE_CLOUD_API_KEY");
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
function resolveProject(options?: GoogleVertexOptions): string {
|
|
70
|
-
const project = options?.project || $
|
|
75
|
+
const project = options?.project || $pickCredentialEnv("GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT");
|
|
71
76
|
if (!project) {
|
|
72
77
|
throw new Error(
|
|
73
78
|
"Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options.",
|
|
@@ -79,10 +84,41 @@ function resolveProject(options?: GoogleVertexOptions): string {
|
|
|
79
84
|
function resolveEndpointHost(location: string): string {
|
|
80
85
|
return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
|
|
81
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
|
+
|
|
82
113
|
function resolveLocation(options?: GoogleVertexOptions): string {
|
|
83
|
-
const location = options?.location || $
|
|
114
|
+
const location = options?.location || $credentialEnv("GOOGLE_CLOUD_LOCATION");
|
|
84
115
|
if (!location) {
|
|
85
116
|
throw new Error("Vertex AI requires a location. Set GOOGLE_CLOUD_LOCATION or pass location in options.");
|
|
86
117
|
}
|
|
87
|
-
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);
|
|
88
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;
|
|
@@ -2,7 +2,7 @@ import * as os from "node:os";
|
|
|
2
2
|
import { scheduler } from "node:timers/promises";
|
|
3
3
|
import {
|
|
4
4
|
$env,
|
|
5
|
-
$
|
|
5
|
+
$pickflag,
|
|
6
6
|
asRecord,
|
|
7
7
|
extractHttpStatusFromError,
|
|
8
8
|
fetchWithRetry,
|
|
@@ -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 {
|
|
@@ -98,12 +102,11 @@ export interface OpenAICodexResponsesOptions extends StreamOptions {
|
|
|
98
102
|
serviceTier?: ServiceTier;
|
|
99
103
|
}
|
|
100
104
|
|
|
101
|
-
const CODEX_DEBUG = $
|
|
105
|
+
const CODEX_DEBUG = $pickflag("GJC_OPENAI_CODE_DEBUG", "PI_CODEX_DEBUG");
|
|
102
106
|
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"]);
|
|
@@ -133,6 +136,31 @@ const CODEX_WEBSOCKET_FATAL_PATTERNS = ["websocket error:", "websocket closed be
|
|
|
133
136
|
/** Max total time to spend retrying 429s with server-provided delays (5 minutes). */
|
|
134
137
|
const CODEX_RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
|
|
135
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Tool names the Codex backend reserves for its own namespaces. Sending a
|
|
141
|
+
* function tool under one of these names is rejected with
|
|
142
|
+
* `Function 'computer.computer' not allowed in namespace 'computer'`.
|
|
143
|
+
* These are renamed on the wire and mapped back on receive so the internal
|
|
144
|
+
* tool name stays canonical everywhere else in the harness.
|
|
145
|
+
*/
|
|
146
|
+
const CODEX_RESERVED_TOOL_WIRE_NAMES: ReadonlyMap<string, string> = new Map([
|
|
147
|
+
["browser", "browser_tool"],
|
|
148
|
+
["computer", "computer_tool"],
|
|
149
|
+
]);
|
|
150
|
+
const CODEX_CANONICAL_TOOL_NAMES: ReadonlyMap<string, string> = new Map(
|
|
151
|
+
Array.from(CODEX_RESERVED_TOOL_WIRE_NAMES, ([canonical, wire]) => [wire, canonical]),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
/** Maps a canonical tool name to the name Codex accepts on the wire. */
|
|
155
|
+
export function codexToolWireName(name: string): string {
|
|
156
|
+
return CODEX_RESERVED_TOOL_WIRE_NAMES.get(name) ?? name;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Maps a Codex wire tool name back to the canonical harness tool name. */
|
|
160
|
+
export function codexToolCanonicalName(wireName: string): string {
|
|
161
|
+
return CODEX_CANONICAL_TOOL_NAMES.get(wireName) ?? wireName;
|
|
162
|
+
}
|
|
163
|
+
|
|
136
164
|
const CODEX_PROGRESS_EVENT_TYPES = new Set([
|
|
137
165
|
"response.created",
|
|
138
166
|
"response.output_item.added",
|
|
@@ -249,6 +277,7 @@ async function retryCodexInitialTransportWithoutToolChoice(
|
|
|
249
277
|
|
|
250
278
|
interface CodexRequestSetup {
|
|
251
279
|
requestSignal: AbortSignal;
|
|
280
|
+
firstEventTimeoutMs: number | undefined;
|
|
252
281
|
wrapCodexSseStream: (source: AsyncGenerator<Record<string, unknown>>) => AsyncGenerator<Record<string, unknown>>;
|
|
253
282
|
requestAbortController: AbortController;
|
|
254
283
|
}
|
|
@@ -299,33 +328,33 @@ function parseCodexPositiveInteger(value: string | undefined, fallback: number):
|
|
|
299
328
|
}
|
|
300
329
|
|
|
301
330
|
function isCodexWebSocketEnvEnabled(): boolean {
|
|
302
|
-
return $
|
|
331
|
+
return $pickflag("GJC_OPENAI_CODE_WEBSOCKET", "PI_CODEX_WEBSOCKET");
|
|
303
332
|
}
|
|
304
333
|
|
|
305
334
|
function getCodexWebSocketRetryBudget(options?: Pick<OpenAICodexResponsesOptions, "streamMaxRetries">): number {
|
|
306
335
|
if (options?.streamMaxRetries !== undefined) {
|
|
307
336
|
return resolveRetryBudget(options.streamMaxRetries, CODEX_WEBSOCKET_RETRY_BUDGET);
|
|
308
337
|
}
|
|
309
|
-
return parseCodexNonNegativeInteger(
|
|
338
|
+
return parseCodexNonNegativeInteger(
|
|
339
|
+
$env.GJC_OPENAI_CODE_WEBSOCKET_RETRY_BUDGET ?? $env.PI_CODEX_WEBSOCKET_RETRY_BUDGET,
|
|
340
|
+
CODEX_WEBSOCKET_RETRY_BUDGET,
|
|
341
|
+
);
|
|
310
342
|
}
|
|
311
343
|
|
|
312
344
|
function getCodexWebSocketRetryDelayMs(retry: number): number {
|
|
313
|
-
const baseDelay = parseCodexPositiveInteger(
|
|
345
|
+
const baseDelay = parseCodexPositiveInteger(
|
|
346
|
+
$env.GJC_OPENAI_CODE_WEBSOCKET_RETRY_DELAY_MS ?? $env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS,
|
|
347
|
+
CODEX_RETRY_DELAY_MS,
|
|
348
|
+
);
|
|
314
349
|
return baseDelay * Math.max(1, retry);
|
|
315
350
|
}
|
|
316
351
|
|
|
317
352
|
function getCodexWebSocketIdleTimeoutMs(overrideMs?: number): number {
|
|
318
|
-
return (
|
|
319
|
-
overrideMs ?? parseCodexPositiveInteger($env.PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS, CODEX_WEBSOCKET_IDLE_TIMEOUT_MS)
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function getCodexWebSocketFirstEventTimeoutMs(idleTimeoutMs: number, overrideMs?: number): number {
|
|
324
353
|
return (
|
|
325
354
|
overrideMs ??
|
|
326
355
|
parseCodexPositiveInteger(
|
|
327
|
-
$env.
|
|
328
|
-
|
|
356
|
+
$env.GJC_OPENAI_CODE_WEBSOCKET_IDLE_TIMEOUT_MS ?? $env.PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS,
|
|
357
|
+
CODEX_WEBSOCKET_IDLE_TIMEOUT_MS,
|
|
329
358
|
)
|
|
330
359
|
);
|
|
331
360
|
}
|
|
@@ -356,8 +385,12 @@ function getCodexProviderSessionState(
|
|
|
356
385
|
return created;
|
|
357
386
|
}
|
|
358
387
|
|
|
359
|
-
function createCodexWebSocketTransportError(message: string): Error {
|
|
360
|
-
|
|
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;
|
|
361
394
|
}
|
|
362
395
|
|
|
363
396
|
function isCodexWebSocketFatalError(error: Error): boolean {
|
|
@@ -370,6 +403,13 @@ function isCodexWebSocketTransportError(error: unknown): boolean {
|
|
|
370
403
|
return error.message.startsWith(CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX);
|
|
371
404
|
}
|
|
372
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
|
+
|
|
373
413
|
function isCodexWebSocketRetryableStreamError(error: unknown): boolean {
|
|
374
414
|
if (!(error instanceof Error) || !isCodexWebSocketTransportError(error)) return false;
|
|
375
415
|
const message = error.message.toLowerCase();
|
|
@@ -467,7 +507,7 @@ export function normalizeCodexToolChoice(
|
|
|
467
507
|
: undefined;
|
|
468
508
|
return customTool
|
|
469
509
|
? { type: "custom", name: customTool.customWireName ?? customTool.name }
|
|
470
|
-
: { type: "function", name };
|
|
510
|
+
: { type: "function", name: codexToolWireName(name) };
|
|
471
511
|
};
|
|
472
512
|
if (choice.type === "function") {
|
|
473
513
|
if ("function" in choice && choice.function?.name) {
|
|
@@ -572,17 +612,22 @@ function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): C
|
|
|
572
612
|
const requestSignal = options?.signal
|
|
573
613
|
? AbortSignal.any([options.signal, requestAbortController.signal])
|
|
574
614
|
: requestAbortController.signal;
|
|
615
|
+
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
|
|
616
|
+
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
|
|
575
617
|
const wrapCodexSseStream = (
|
|
576
618
|
source: AsyncGenerator<Record<string, unknown>>,
|
|
577
619
|
): AsyncGenerator<Record<string, unknown>> =>
|
|
578
620
|
iterateWithIdleTimeout(source, {
|
|
579
|
-
idleTimeoutMs
|
|
621
|
+
idleTimeoutMs,
|
|
622
|
+
firstItemTimeoutMs: firstEventTimeoutMs,
|
|
623
|
+
firstItemErrorMessage: "OpenAI Codex SSE stream timed out while waiting for the first event",
|
|
580
624
|
errorMessage: "OpenAI Codex SSE stream stalled while waiting for the next event",
|
|
581
625
|
onIdle: () => requestAbortController.abort(),
|
|
626
|
+
onFirstItemTimeout: () => requestAbortController.abort(),
|
|
582
627
|
abortSignal: options?.signal,
|
|
583
628
|
isProgressItem: isCodexStreamProgressEvent,
|
|
584
629
|
});
|
|
585
|
-
return { requestAbortController, requestSignal, wrapCodexSseStream };
|
|
630
|
+
return { requestAbortController, requestSignal, firstEventTimeoutMs, wrapCodexSseStream };
|
|
586
631
|
}
|
|
587
632
|
|
|
588
633
|
async function buildCodexRequestContext(
|
|
@@ -803,6 +848,7 @@ async function openCodexWebSocketTransport(
|
|
|
803
848
|
websocketState,
|
|
804
849
|
requestSetup.requestSignal,
|
|
805
850
|
options,
|
|
851
|
+
requestSetup.firstEventTimeoutMs,
|
|
806
852
|
);
|
|
807
853
|
return { eventStream, requestBodyForState, transport: "websocket" };
|
|
808
854
|
}
|
|
@@ -1090,7 +1136,7 @@ function createOutputBlockForItem(item: CodexEventItem): CodexOutputBlock | null
|
|
|
1090
1136
|
return {
|
|
1091
1137
|
type: "toolCall",
|
|
1092
1138
|
id: encodeResponsesToolCallId(item.call_id, item.id),
|
|
1093
|
-
name: item.name,
|
|
1139
|
+
name: codexToolCanonicalName(item.name),
|
|
1094
1140
|
arguments: {},
|
|
1095
1141
|
partialJson: item.arguments || "",
|
|
1096
1142
|
};
|
|
@@ -1348,7 +1394,7 @@ function handleOutputItemDone(
|
|
|
1348
1394
|
const toolCall: ToolCall = {
|
|
1349
1395
|
type: "toolCall",
|
|
1350
1396
|
id,
|
|
1351
|
-
name: item.name,
|
|
1397
|
+
name: codexToolCanonicalName(item.name),
|
|
1352
1398
|
arguments: parseStreamingJson(item.arguments || "{}"),
|
|
1353
1399
|
};
|
|
1354
1400
|
runtime.canSafelyReplayWebsocketOverSse = false;
|
|
@@ -1443,7 +1489,7 @@ async function recoverCodexStreamError(
|
|
|
1443
1489
|
runtime: CodexStreamRuntime,
|
|
1444
1490
|
error: unknown,
|
|
1445
1491
|
): Promise<boolean> {
|
|
1446
|
-
if (
|
|
1492
|
+
if (isCodexFirstEventTimeout(error)) return false;
|
|
1447
1493
|
if (await tryRetryWithoutForcedToolChoice(context, runtime, error)) {
|
|
1448
1494
|
return true;
|
|
1449
1495
|
}
|
|
@@ -2161,7 +2207,6 @@ function headersToRecord(headers: Headers): Record<string, string> {
|
|
|
2161
2207
|
|
|
2162
2208
|
interface CodexWebSocketConnectionOptions {
|
|
2163
2209
|
idleTimeoutMs: number;
|
|
2164
|
-
firstEventTimeoutMs: number;
|
|
2165
2210
|
onHandshakeHeaders?: (headers: Headers) => void;
|
|
2166
2211
|
}
|
|
2167
2212
|
|
|
@@ -2169,7 +2214,6 @@ class CodexWebSocketConnection {
|
|
|
2169
2214
|
#url: string;
|
|
2170
2215
|
#headers: Record<string, string>;
|
|
2171
2216
|
#idleTimeoutMs: number;
|
|
2172
|
-
#firstEventTimeoutMs: number;
|
|
2173
2217
|
#onHandshakeHeaders?: (headers: Headers) => void;
|
|
2174
2218
|
#socket: Bun.WebSocket | null = null;
|
|
2175
2219
|
#queue: Array<Record<string, unknown> | Error | null> = [];
|
|
@@ -2181,7 +2225,6 @@ class CodexWebSocketConnection {
|
|
|
2181
2225
|
this.#url = url;
|
|
2182
2226
|
this.#headers = headers;
|
|
2183
2227
|
this.#idleTimeoutMs = options.idleTimeoutMs;
|
|
2184
|
-
this.#firstEventTimeoutMs = options.firstEventTimeoutMs;
|
|
2185
2228
|
this.#onHandshakeHeaders = options.onHandshakeHeaders;
|
|
2186
2229
|
}
|
|
2187
2230
|
|
|
@@ -2311,6 +2354,7 @@ class CodexWebSocketConnection {
|
|
|
2311
2354
|
async *streamRequest(
|
|
2312
2355
|
request: Record<string, unknown>,
|
|
2313
2356
|
signal?: AbortSignal,
|
|
2357
|
+
firstEventTimeoutMs?: number,
|
|
2314
2358
|
): AsyncGenerator<Record<string, unknown>> {
|
|
2315
2359
|
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
|
|
2316
2360
|
throw createCodexWebSocketTransportError("websocket connection is unavailable");
|
|
@@ -2333,11 +2377,11 @@ class CodexWebSocketConnection {
|
|
|
2333
2377
|
|
|
2334
2378
|
try {
|
|
2335
2379
|
this.#socket.send(JSON.stringify(request));
|
|
2336
|
-
let
|
|
2380
|
+
let sawFirstProgress = false;
|
|
2337
2381
|
let lastProgressAt = Date.now();
|
|
2338
2382
|
while (true) {
|
|
2339
|
-
let timeoutMs =
|
|
2340
|
-
if (
|
|
2383
|
+
let timeoutMs = firstEventTimeoutMs;
|
|
2384
|
+
if (sawFirstProgress) {
|
|
2341
2385
|
timeoutMs = this.#idleTimeoutMs - (Date.now() - lastProgressAt);
|
|
2342
2386
|
if (timeoutMs <= 0) {
|
|
2343
2387
|
throw createCodexWebSocketTransportError("idle timeout waiting for websocket");
|
|
@@ -2345,7 +2389,8 @@ class CodexWebSocketConnection {
|
|
|
2345
2389
|
}
|
|
2346
2390
|
const next = await this.#nextMessage(
|
|
2347
2391
|
timeoutMs,
|
|
2348
|
-
|
|
2392
|
+
sawFirstProgress ? "idle timeout waiting for websocket" : "timeout waiting for first websocket event",
|
|
2393
|
+
sawFirstProgress ? undefined : STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE,
|
|
2349
2394
|
);
|
|
2350
2395
|
if (next instanceof Error) {
|
|
2351
2396
|
throw next;
|
|
@@ -2353,8 +2398,8 @@ class CodexWebSocketConnection {
|
|
|
2353
2398
|
if (next === null) {
|
|
2354
2399
|
throw createCodexWebSocketTransportError("websocket closed before response completion");
|
|
2355
2400
|
}
|
|
2356
|
-
sawFirstEvent = true;
|
|
2357
2401
|
if (isCodexStreamProgressEvent(next)) {
|
|
2402
|
+
sawFirstProgress = true;
|
|
2358
2403
|
lastProgressAt = Date.now();
|
|
2359
2404
|
}
|
|
2360
2405
|
yield next;
|
|
@@ -2390,13 +2435,17 @@ class CodexWebSocketConnection {
|
|
|
2390
2435
|
if (waiter) waiter();
|
|
2391
2436
|
}
|
|
2392
2437
|
|
|
2393
|
-
async #nextMessage(
|
|
2438
|
+
async #nextMessage(
|
|
2439
|
+
timeoutMs: number | undefined,
|
|
2440
|
+
timeoutReason: string,
|
|
2441
|
+
providerCode?: string,
|
|
2442
|
+
): Promise<Record<string, unknown> | Error | null> {
|
|
2394
2443
|
while (this.#queue.length === 0) {
|
|
2395
2444
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
2396
2445
|
this.#waiters.push(resolve);
|
|
2397
2446
|
let timedOut = false;
|
|
2398
2447
|
let timeout: NodeJS.Timeout | undefined;
|
|
2399
|
-
if (timeoutMs > 0) {
|
|
2448
|
+
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
2400
2449
|
timeout = setTimeout(() => {
|
|
2401
2450
|
timedOut = true;
|
|
2402
2451
|
const waiterIndex = this.#waiters.indexOf(resolve);
|
|
@@ -2409,7 +2458,10 @@ class CodexWebSocketConnection {
|
|
|
2409
2458
|
await promise;
|
|
2410
2459
|
if (timeout) clearTimeout(timeout);
|
|
2411
2460
|
if (timedOut && this.#queue.length === 0) {
|
|
2412
|
-
|
|
2461
|
+
if (providerCode === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE) {
|
|
2462
|
+
this.close("first-event-timeout");
|
|
2463
|
+
}
|
|
2464
|
+
return createCodexWebSocketTransportError(timeoutReason, providerCode);
|
|
2413
2465
|
}
|
|
2414
2466
|
}
|
|
2415
2467
|
return this.#queue.shift() ?? null;
|
|
@@ -2438,7 +2490,6 @@ async function getOrCreateCodexWebSocketConnection(
|
|
|
2438
2490
|
const idleTimeoutMs = getCodexWebSocketIdleTimeoutMs(options?.streamIdleTimeoutMs);
|
|
2439
2491
|
state.connection = new CodexWebSocketConnection(url, headerRecord, {
|
|
2440
2492
|
idleTimeoutMs,
|
|
2441
|
-
firstEventTimeoutMs: getCodexWebSocketFirstEventTimeoutMs(idleTimeoutMs, options?.streamFirstEventTimeoutMs),
|
|
2442
2493
|
onHandshakeHeaders: handshakeHeaders => {
|
|
2443
2494
|
updateCodexSessionMetadataFromHeaders(state, handshakeHeaders);
|
|
2444
2495
|
},
|
|
@@ -2509,9 +2560,10 @@ async function openCodexWebSocketEventStream(
|
|
|
2509
2560
|
state: CodexWebSocketSessionState,
|
|
2510
2561
|
signal?: AbortSignal,
|
|
2511
2562
|
options?: Pick<OpenAICodexResponsesOptions, "streamFirstEventTimeoutMs" | "streamIdleTimeoutMs">,
|
|
2563
|
+
firstEventTimeoutMs?: number,
|
|
2512
2564
|
): Promise<AsyncGenerator<Record<string, unknown>>> {
|
|
2513
2565
|
const connection = await getOrCreateCodexWebSocketConnection(state, url, headers, signal, options);
|
|
2514
|
-
return connection.streamRequest(request, signal);
|
|
2566
|
+
return connection.streamRequest(request, signal, firstEventTimeoutMs);
|
|
2515
2567
|
}
|
|
2516
2568
|
|
|
2517
2569
|
function createCodexHeaders(
|
|
@@ -2690,6 +2742,13 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
|
|
|
2690
2742
|
true,
|
|
2691
2743
|
customCallIds,
|
|
2692
2744
|
);
|
|
2745
|
+
for (const item of outputItems) {
|
|
2746
|
+
// Reconstructed (non-raw) history carries canonical tool names; the
|
|
2747
|
+
// wire form has to match the renamed `tools` entries.
|
|
2748
|
+
if (item.type === "function_call" && typeof item.name === "string") {
|
|
2749
|
+
item.name = codexToolWireName(item.name);
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2693
2752
|
if (outputItems.length > 0) {
|
|
2694
2753
|
messages.push(...outputItems);
|
|
2695
2754
|
}
|
|
@@ -2776,7 +2835,7 @@ export function convertOpenAICodexResponsesTools(
|
|
|
2776
2835
|
const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(baseParameters, strict);
|
|
2777
2836
|
return {
|
|
2778
2837
|
type: "function",
|
|
2779
|
-
name: tool.name,
|
|
2838
|
+
name: codexToolWireName(tool.name),
|
|
2780
2839
|
description: tool.description || "",
|
|
2781
2840
|
parameters,
|
|
2782
2841
|
...(effectiveStrict && { strict: true }),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $credentialEnv, $env,
|
|
1
|
+
import { $credentialEnv, $env, extractHttpStatusFromError, logger } from "@gajae-code/utils";
|
|
2
2
|
import OpenAI from "openai";
|
|
3
3
|
import type {
|
|
4
4
|
ChatCompletionAssistantMessageParam,
|
|
@@ -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,
|
|
@@ -102,7 +101,9 @@ function resolveOpenAIProviderBaseUrl(
|
|
|
102
101
|
authCredentialType: "api_key" | "oauth" | undefined,
|
|
103
102
|
): string {
|
|
104
103
|
if (authCredentialType === "oauth") return OPENAI_DEFAULT_BASE_URL;
|
|
105
|
-
|
|
104
|
+
// Trusted sources only: this base URL becomes the request endpoint that carries
|
|
105
|
+
// the OpenAI credential, and `$env` merges the caller's `cwd/.env`.
|
|
106
|
+
const envBaseUrl = $credentialEnv("OPENAI_BASE_URL");
|
|
106
107
|
const configuredBaseUrl = baseUrl?.trim();
|
|
107
108
|
if (envBaseUrl && (!configuredBaseUrl || isDefaultOpenAIBaseUrl(configuredBaseUrl))) {
|
|
108
109
|
return envBaseUrl;
|
|
@@ -110,6 +111,14 @@ function resolveOpenAIProviderBaseUrl(
|
|
|
110
111
|
return configuredBaseUrl || envBaseUrl || OPENAI_DEFAULT_BASE_URL;
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/** Test seam: the provider base URL as resolved from trusted env. */
|
|
115
|
+
export function resolveOpenAICompletionsBaseUrlForTest(
|
|
116
|
+
baseUrl: string | undefined,
|
|
117
|
+
authCredentialType: "api_key" | "oauth" | undefined,
|
|
118
|
+
): string {
|
|
119
|
+
return resolveOpenAIProviderBaseUrl(baseUrl, authCredentialType);
|
|
120
|
+
}
|
|
121
|
+
|
|
113
122
|
/**
|
|
114
123
|
* Normalize tool call ID for Mistral.
|
|
115
124
|
* Mistral requires tool IDs to be exactly 9 alphanumeric characters (a-z, A-Z, 0-9).
|
|
@@ -447,7 +456,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
447
456
|
const output: AssistantMessage = createInitialResponsesAssistantMessage(model.api, model.provider, model.id);
|
|
448
457
|
let rawRequestDump: RawHttpRequestDump | undefined;
|
|
449
458
|
const abortTracker = createAbortSourceTracker(options?.signal);
|
|
450
|
-
const firstEventTimeoutAbortError = new Error(OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE);
|
|
451
459
|
const { requestAbortController, requestSignal } = abortTracker;
|
|
452
460
|
|
|
453
461
|
try {
|
|
@@ -569,10 +577,8 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
569
577
|
model.provider === "alibaba-token-plan"
|
|
570
578
|
? ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS
|
|
571
579
|
: getProviderFirstEventTimeoutFallbackMs(model.provider);
|
|
572
|
-
const
|
|
573
|
-
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs)
|
|
574
|
-
() => abortTracker.abortLocally(firstEventTimeoutAbortError),
|
|
575
|
-
);
|
|
580
|
+
const firstEventTimeoutMs =
|
|
581
|
+
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs);
|
|
576
582
|
if (premiumRequestsTotal !== undefined) {
|
|
577
583
|
output.usage.premiumRequests = premiumRequestsTotal;
|
|
578
584
|
}
|
|
@@ -759,10 +765,12 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
759
765
|
};
|
|
760
766
|
|
|
761
767
|
for await (const chunk of iterateWithIdleTimeout(openaiStream, {
|
|
762
|
-
|
|
768
|
+
firstItemTimeoutMs: firstEventTimeoutMs,
|
|
769
|
+
firstItemErrorMessage: OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE,
|
|
763
770
|
idleTimeoutMs,
|
|
764
771
|
errorMessage: "OpenAI completions stream stalled while waiting for the next event",
|
|
765
772
|
onIdle: () => requestAbortController.abort(),
|
|
773
|
+
onFirstItemTimeout: () => requestAbortController.abort(),
|
|
766
774
|
abortSignal: options?.signal,
|
|
767
775
|
isProgressItem: isOpenAICompletionsProgressChunk,
|
|
768
776
|
})) {
|
|
@@ -973,14 +981,17 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
973
981
|
stream.end();
|
|
974
982
|
} catch (error) {
|
|
975
983
|
for (const block of output.content) delete (block as any).index;
|
|
976
|
-
const
|
|
984
|
+
const localAbortReason = abortTracker.getLocalAbortReason();
|
|
977
985
|
const capturedErrorResponse = getCapturedErrorResponse?.();
|
|
978
986
|
output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
|
|
979
|
-
output.errorStatus =
|
|
980
|
-
|
|
987
|
+
output.errorStatus =
|
|
988
|
+
extractHttpStatusFromError(localAbortReason ?? error) ??
|
|
989
|
+
(localAbortReason ? undefined : capturedErrorResponse?.status);
|
|
990
|
+
output.transportFailure = localAbortReason
|
|
991
|
+
? transportFailureFacts(localAbortReason)
|
|
992
|
+
: transportFailureFacts(error, capturedErrorResponse);
|
|
981
993
|
output.errorMessage =
|
|
982
|
-
|
|
983
|
-
(await finalizeErrorMessage(error, rawRequestDump, capturedErrorResponse));
|
|
994
|
+
localAbortReason?.message ?? (await finalizeErrorMessage(error, rawRequestDump, capturedErrorResponse));
|
|
984
995
|
// Some providers via OpenRouter include extra details here.
|
|
985
996
|
const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw;
|
|
986
997
|
if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
|