@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +3 -0
  3. package/dist/types/model-thinking.d.ts +15 -0
  4. package/dist/types/provider-models/openai-compat.d.ts +7 -0
  5. package/dist/types/providers/anthropic.d.ts +2 -1
  6. package/dist/types/providers/azure-openai-responses.d.ts +8 -1
  7. package/dist/types/providers/google-auth.d.ts +2 -0
  8. package/dist/types/providers/google-gemini-headers.d.ts +1 -1
  9. package/dist/types/providers/google-vertex.d.ts +4 -0
  10. package/dist/types/providers/openai-codex-responses.d.ts +4 -0
  11. package/dist/types/providers/openai-completions.d.ts +2 -0
  12. package/dist/types/providers/openai-responses.d.ts +2 -0
  13. package/dist/types/types.d.ts +1 -1
  14. package/dist/types/usage/grok-cli.d.ts +3 -1
  15. package/dist/types/usage/kimi.d.ts +2 -0
  16. package/dist/types/utils/anthropic-auth.d.ts +8 -0
  17. package/dist/types/utils/fallback-transport.d.ts +2 -0
  18. package/dist/types/utils/foundry.d.ts +10 -0
  19. package/dist/types/utils/http-inspector.d.ts +23 -0
  20. package/dist/types/utils/idle-iterator.d.ts +4 -0
  21. package/dist/types/utils/oauth/bizrouter.d.ts +1 -0
  22. package/dist/types/utils/oauth/kimi.d.ts +2 -0
  23. package/dist/types/utils/oauth/perplexity.d.ts +2 -7
  24. package/dist/types/utils/oauth/types.d.ts +1 -1
  25. package/package.json +2 -2
  26. package/src/auth-storage.ts +6 -0
  27. package/src/cli.ts +1 -0
  28. package/src/model-thinking.ts +19 -0
  29. package/src/models.json +72 -0
  30. package/src/provider-models/descriptors.ts +7 -0
  31. package/src/provider-models/openai-compat.ts +67 -6
  32. package/src/providers/amazon-bedrock.ts +5 -20
  33. package/src/providers/anthropic.ts +103 -48
  34. package/src/providers/azure-openai-responses.ts +44 -19
  35. package/src/providers/google-auth.ts +13 -2
  36. package/src/providers/google-gemini-headers.ts +1 -1
  37. package/src/providers/google-vertex.ts +41 -5
  38. package/src/providers/ollama.ts +32 -4
  39. package/src/providers/openai-codex-responses.ts +97 -38
  40. package/src/providers/openai-completions.ts +25 -14
  41. package/src/providers/openai-responses.ts +22 -20
  42. package/src/providers/register-builtins.ts +12 -2
  43. package/src/stream.ts +1 -0
  44. package/src/types.ts +1 -0
  45. package/src/usage/claude.ts +2 -1
  46. package/src/usage/grok-cli.ts +12 -1
  47. package/src/usage/kimi.ts +16 -2
  48. package/src/utils/anthropic-auth.ts +11 -3
  49. package/src/utils/fallback-transport.ts +17 -10
  50. package/src/utils/foundry.ts +12 -2
  51. package/src/utils/http-inspector.ts +124 -1
  52. package/src/utils/idle-iterator.ts +12 -3
  53. package/src/utils/oauth/bizrouter.ts +15 -0
  54. package/src/utils/oauth/index.ts +6 -0
  55. package/src/utils/oauth/kimi.ts +17 -2
  56. package/src/utils/oauth/perplexity.ts +21 -2
  57. package/src/utils/oauth/types.ts +1 -0
  58. package/src/utils/schema/adapt.ts +2 -2
  59. package/src/utils/tool-choice-capability.ts +2 -1
@@ -1,4 +1,4 @@
1
- import { $env } from "@gajae-code/utils";
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 || $env.GOOGLE_CLOUD_API_KEY;
71
+ return realKey || $credentialEnv("GOOGLE_CLOUD_API_KEY");
67
72
  }
68
73
 
69
74
  function resolveProject(options?: GoogleVertexOptions): string {
70
- const project = options?.project || $env.GOOGLE_CLOUD_PROJECT || $env.GCLOUD_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 || $env.GOOGLE_CLOUD_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
  }
@@ -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
- toolCall.arguments = parseStreamingJson<Record<string, unknown>>(toolCall.partialJson);
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
- $flag,
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 { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
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 = $flag("PI_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 $flag("PI_CODEX_WEBSOCKET");
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($env.PI_CODEX_WEBSOCKET_RETRY_BUDGET, CODEX_WEBSOCKET_RETRY_BUDGET);
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($env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS, CODEX_RETRY_DELAY_MS);
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.PI_CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS,
328
- Math.min(CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS, idleTimeoutMs),
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
- return new Error(`${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: ${message}`);
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: options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(),
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 (context.options?.fallbackManaged) return false;
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 sawFirstEvent = false;
2380
+ let sawFirstProgress = false;
2337
2381
  let lastProgressAt = Date.now();
2338
2382
  while (true) {
2339
- let timeoutMs = this.#firstEventTimeoutMs;
2340
- if (sawFirstEvent) {
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
- sawFirstEvent ? "idle timeout waiting for websocket" : "timeout waiting for first websocket event",
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(timeoutMs: number, timeoutReason: string): Promise<Record<string, unknown> | Error | null> {
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
- return createCodexWebSocketTransportError(timeoutReason);
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, $inheritedEnv, extractHttpStatusFromError, logger } from "@gajae-code/utils";
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
- const envBaseUrl = $inheritedEnv("OPENAI_BASE_URL") ?? $env.OPENAI_BASE_URL?.trim();
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 firstEventWatchdog = createWatchdog(
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
- watchdog: firstEventWatchdog,
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 firstEventTimeoutError = abortTracker.getLocalAbortReason();
984
+ const localAbortReason = abortTracker.getLocalAbortReason();
977
985
  const capturedErrorResponse = getCapturedErrorResponse?.();
978
986
  output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
979
- output.errorStatus = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status;
980
- output.transportFailure = transportFailureFacts(error, capturedErrorResponse);
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
- firstEventTimeoutError?.message ??
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}`;