@gajae-code/ai 0.11.10 → 0.12.0

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 (46) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/README.md +3 -0
  3. package/dist/types/provider-models/openai-compat.d.ts +7 -0
  4. package/dist/types/providers/anthropic.d.ts +2 -1
  5. package/dist/types/providers/azure-openai-responses.d.ts +6 -1
  6. package/dist/types/providers/google-auth.d.ts +2 -0
  7. package/dist/types/providers/google-gemini-headers.d.ts +1 -1
  8. package/dist/types/providers/google-vertex.d.ts +2 -0
  9. package/dist/types/providers/openai-codex-responses.d.ts +4 -0
  10. package/dist/types/providers/openai-completions.d.ts +2 -0
  11. package/dist/types/providers/openai-responses.d.ts +2 -0
  12. package/dist/types/types.d.ts +1 -1
  13. package/dist/types/usage/grok-cli.d.ts +3 -1
  14. package/dist/types/usage/kimi.d.ts +2 -0
  15. package/dist/types/utils/anthropic-auth.d.ts +8 -0
  16. package/dist/types/utils/foundry.d.ts +10 -0
  17. package/dist/types/utils/http-inspector.d.ts +13 -0
  18. package/dist/types/utils/idle-iterator.d.ts +2 -2
  19. package/dist/types/utils/oauth/bizrouter.d.ts +1 -0
  20. package/dist/types/utils/oauth/types.d.ts +1 -1
  21. package/package.json +2 -2
  22. package/src/auth-storage.ts +6 -0
  23. package/src/cli.ts +1 -0
  24. package/src/models.json +72 -0
  25. package/src/provider-models/descriptors.ts +7 -0
  26. package/src/provider-models/openai-compat.ts +67 -6
  27. package/src/providers/anthropic.ts +44 -4
  28. package/src/providers/azure-openai-responses.ts +16 -3
  29. package/src/providers/google-auth.ts +13 -2
  30. package/src/providers/google-gemini-headers.ts +1 -1
  31. package/src/providers/google-vertex.ts +7 -2
  32. package/src/providers/openai-codex-responses.ts +52 -10
  33. package/src/providers/openai-completions.ts +12 -2
  34. package/src/providers/openai-responses.ts +13 -9
  35. package/src/stream.ts +1 -0
  36. package/src/types.ts +1 -0
  37. package/src/usage/claude.ts +21 -3
  38. package/src/usage/grok-cli.ts +12 -1
  39. package/src/usage/kimi.ts +16 -2
  40. package/src/utils/anthropic-auth.ts +11 -3
  41. package/src/utils/foundry.ts +12 -2
  42. package/src/utils/http-inspector.ts +77 -0
  43. package/src/utils/idle-iterator.ts +15 -7
  44. package/src/utils/oauth/bizrouter.ts +15 -0
  45. package/src/utils/oauth/index.ts +6 -0
  46. package/src/utils/oauth/types.ts +1 -0
@@ -1,4 +1,4 @@
1
- import { $env, extractHttpStatusFromError, logger } from "@gajae-code/utils";
1
+ import { $credentialEnv, $env, extractHttpStatusFromError, logger } from "@gajae-code/utils";
2
2
  import { AzureOpenAI } from "openai";
3
3
  import type {
4
4
  Tool as OpenAITool,
@@ -234,8 +234,13 @@ function resolveAzureConfig(
234
234
  ): { baseUrl: string; apiVersion: string } {
235
235
  const apiVersion = options?.azureApiVersion || $env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION;
236
236
 
237
- const baseUrl = options?.azureBaseUrl?.trim() || $env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
238
- const resourceName = options?.azureResourceName || $env.AZURE_OPENAI_RESOURCE_NAME;
237
+ // Trusted sources only: both of these decide the request endpoint that carries
238
+ // the Azure credential, and `$env` merges the caller's `cwd/.env`. The resource
239
+ // name is the alternate constructor for the same host
240
+ // (`https://<resource>.openai.azure.com/openai/v1`), so it needs the same
241
+ // boundary as the explicit base URL.
242
+ const baseUrl = options?.azureBaseUrl?.trim() || $credentialEnv("AZURE_OPENAI_BASE_URL") || undefined;
243
+ const resourceName = options?.azureResourceName || $credentialEnv("AZURE_OPENAI_RESOURCE_NAME");
239
244
 
240
245
  let resolvedBaseUrl = baseUrl;
241
246
 
@@ -259,6 +264,14 @@ function resolveAzureConfig(
259
264
  };
260
265
  }
261
266
 
267
+ /** Test seam: the Azure endpoint config as resolved from trusted env. */
268
+ export function resolveAzureConfigForTest(
269
+ model: Model<"azure-openai-responses">,
270
+ options?: AzureOpenAIResponsesOptions,
271
+ ): { baseUrl: string; apiVersion: string } {
272
+ return resolveAzureConfig(model, options);
273
+ }
274
+
262
275
  function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) {
263
276
  if (!apiKey) {
264
277
  const envKey = $env.AZURE_OPENAI_API_KEY;
@@ -15,7 +15,7 @@
15
15
  import { Buffer } from "node:buffer";
16
16
  import * as os from "node:os";
17
17
  import * as path from "node:path";
18
- import { $envpos, isEnoent, logger } from "@gajae-code/utils";
18
+ import { $credentialEnv, $envpos, isEnoent, logger } from "@gajae-code/utils";
19
19
  import type { FetchImpl } from "../types";
20
20
 
21
21
  const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -70,8 +70,19 @@ async function readJsonFile<T>(filePath: string): Promise<T | undefined> {
70
70
  }
71
71
  }
72
72
 
73
+ /** Test seam: the ADC credentials file path as resolved from trusted env. */
74
+ export function resolveAdcCredentialsPathForTest(): string | undefined {
75
+ return $credentialEnv("GOOGLE_APPLICATION_CREDENTIALS");
76
+ }
77
+
73
78
  async function loadAdcCredentials(): Promise<{ source: string; creds: AdcFileCredentials } | undefined> {
74
- const gacPath = Bun.env.GOOGLE_APPLICATION_CREDENTIALS;
79
+ // Trusted sources only: this path is read as service-account / authorized-user
80
+ // credentials and exchanged for a Google access token, so whatever can set it
81
+ // chooses the identity the agent authenticates as. `Bun.env` is `process.env`
82
+ // and the env module merges the caller's `cwd/.env` into it, so reading it
83
+ // there would let repository content point this at a key file it ships.
84
+ // `stream.ts` already resolves the same variable through `$credentialEnv`.
85
+ const gacPath = $credentialEnv("GOOGLE_APPLICATION_CREDENTIALS");
75
86
  if (gacPath) {
76
87
  const creds = await readJsonFile<AdcFileCredentials>(gacPath);
77
88
  if (!creds) {
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export const GEMINI_CLI_VERSION_ENV = "GJC_AI_GEMINI_CLI_VERSION";
7
7
  export const LEGACY_GEMINI_CLI_VERSION_ENV = "PI_AI_GEMINI_CLI_VERSION";
8
- export const DEFAULT_GEMINI_CLI_VERSION = "0.50.0";
8
+ export const DEFAULT_GEMINI_CLI_VERSION = "0.52.0";
9
9
 
10
10
  export function getGeminiCliUserAgent(modelId = "gemini-3.1-pro-preview"): string {
11
11
  const version =
@@ -1,4 +1,4 @@
1
- import { $env } from "@gajae-code/utils";
1
+ import { $credentialEnv, $env } 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,12 +58,17 @@ 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 {
@@ -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,
@@ -98,7 +98,7 @@ export interface OpenAICodexResponsesOptions extends StreamOptions {
98
98
  serviceTier?: ServiceTier;
99
99
  }
100
100
 
101
- const CODEX_DEBUG = $flag("PI_CODEX_DEBUG");
101
+ const CODEX_DEBUG = $pickflag("GJC_OPENAI_CODE_DEBUG", "PI_CODEX_DEBUG");
102
102
  const CODEX_MAX_RETRIES = 5;
103
103
  const CODEX_RETRY_DELAY_MS = 500;
104
104
  const CODEX_WEBSOCKET_CONNECT_TIMEOUT_MS = 10000;
@@ -133,6 +133,31 @@ const CODEX_WEBSOCKET_FATAL_PATTERNS = ["websocket error:", "websocket closed be
133
133
  /** Max total time to spend retrying 429s with server-provided delays (5 minutes). */
134
134
  const CODEX_RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
135
135
 
136
+ /**
137
+ * Tool names the Codex backend reserves for its own namespaces. Sending a
138
+ * function tool under one of these names is rejected with
139
+ * `Function 'computer.computer' not allowed in namespace 'computer'`.
140
+ * These are renamed on the wire and mapped back on receive so the internal
141
+ * tool name stays canonical everywhere else in the harness.
142
+ */
143
+ const CODEX_RESERVED_TOOL_WIRE_NAMES: ReadonlyMap<string, string> = new Map([
144
+ ["browser", "browser_tool"],
145
+ ["computer", "computer_tool"],
146
+ ]);
147
+ const CODEX_CANONICAL_TOOL_NAMES: ReadonlyMap<string, string> = new Map(
148
+ Array.from(CODEX_RESERVED_TOOL_WIRE_NAMES, ([canonical, wire]) => [wire, canonical]),
149
+ );
150
+
151
+ /** Maps a canonical tool name to the name Codex accepts on the wire. */
152
+ export function codexToolWireName(name: string): string {
153
+ return CODEX_RESERVED_TOOL_WIRE_NAMES.get(name) ?? name;
154
+ }
155
+
156
+ /** Maps a Codex wire tool name back to the canonical harness tool name. */
157
+ export function codexToolCanonicalName(wireName: string): string {
158
+ return CODEX_CANONICAL_TOOL_NAMES.get(wireName) ?? wireName;
159
+ }
160
+
136
161
  const CODEX_PROGRESS_EVENT_TYPES = new Set([
137
162
  "response.created",
138
163
  "response.output_item.added",
@@ -299,24 +324,34 @@ function parseCodexPositiveInteger(value: string | undefined, fallback: number):
299
324
  }
300
325
 
301
326
  function isCodexWebSocketEnvEnabled(): boolean {
302
- return $flag("PI_CODEX_WEBSOCKET");
327
+ return $pickflag("GJC_OPENAI_CODE_WEBSOCKET", "PI_CODEX_WEBSOCKET");
303
328
  }
304
329
 
305
330
  function getCodexWebSocketRetryBudget(options?: Pick<OpenAICodexResponsesOptions, "streamMaxRetries">): number {
306
331
  if (options?.streamMaxRetries !== undefined) {
307
332
  return resolveRetryBudget(options.streamMaxRetries, CODEX_WEBSOCKET_RETRY_BUDGET);
308
333
  }
309
- return parseCodexNonNegativeInteger($env.PI_CODEX_WEBSOCKET_RETRY_BUDGET, CODEX_WEBSOCKET_RETRY_BUDGET);
334
+ return parseCodexNonNegativeInteger(
335
+ $env.GJC_OPENAI_CODE_WEBSOCKET_RETRY_BUDGET ?? $env.PI_CODEX_WEBSOCKET_RETRY_BUDGET,
336
+ CODEX_WEBSOCKET_RETRY_BUDGET,
337
+ );
310
338
  }
311
339
 
312
340
  function getCodexWebSocketRetryDelayMs(retry: number): number {
313
- const baseDelay = parseCodexPositiveInteger($env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS, CODEX_RETRY_DELAY_MS);
341
+ const baseDelay = parseCodexPositiveInteger(
342
+ $env.GJC_OPENAI_CODE_WEBSOCKET_RETRY_DELAY_MS ?? $env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS,
343
+ CODEX_RETRY_DELAY_MS,
344
+ );
314
345
  return baseDelay * Math.max(1, retry);
315
346
  }
316
347
 
317
348
  function getCodexWebSocketIdleTimeoutMs(overrideMs?: number): number {
318
349
  return (
319
- overrideMs ?? parseCodexPositiveInteger($env.PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS, CODEX_WEBSOCKET_IDLE_TIMEOUT_MS)
350
+ overrideMs ??
351
+ parseCodexPositiveInteger(
352
+ $env.GJC_OPENAI_CODE_WEBSOCKET_IDLE_TIMEOUT_MS ?? $env.PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS,
353
+ CODEX_WEBSOCKET_IDLE_TIMEOUT_MS,
354
+ )
320
355
  );
321
356
  }
322
357
 
@@ -467,7 +502,7 @@ export function normalizeCodexToolChoice(
467
502
  : undefined;
468
503
  return customTool
469
504
  ? { type: "custom", name: customTool.customWireName ?? customTool.name }
470
- : { type: "function", name };
505
+ : { type: "function", name: codexToolWireName(name) };
471
506
  };
472
507
  if (choice.type === "function") {
473
508
  if ("function" in choice && choice.function?.name) {
@@ -1090,7 +1125,7 @@ function createOutputBlockForItem(item: CodexEventItem): CodexOutputBlock | null
1090
1125
  return {
1091
1126
  type: "toolCall",
1092
1127
  id: encodeResponsesToolCallId(item.call_id, item.id),
1093
- name: item.name,
1128
+ name: codexToolCanonicalName(item.name),
1094
1129
  arguments: {},
1095
1130
  partialJson: item.arguments || "",
1096
1131
  };
@@ -1348,7 +1383,7 @@ function handleOutputItemDone(
1348
1383
  const toolCall: ToolCall = {
1349
1384
  type: "toolCall",
1350
1385
  id,
1351
- name: item.name,
1386
+ name: codexToolCanonicalName(item.name),
1352
1387
  arguments: parseStreamingJson(item.arguments || "{}"),
1353
1388
  };
1354
1389
  runtime.canSafelyReplayWebsocketOverSse = false;
@@ -2690,6 +2725,13 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
2690
2725
  true,
2691
2726
  customCallIds,
2692
2727
  );
2728
+ for (const item of outputItems) {
2729
+ // Reconstructed (non-raw) history carries canonical tool names; the
2730
+ // wire form has to match the renamed `tools` entries.
2731
+ if (item.type === "function_call" && typeof item.name === "string") {
2732
+ item.name = codexToolWireName(item.name);
2733
+ }
2734
+ }
2693
2735
  if (outputItems.length > 0) {
2694
2736
  messages.push(...outputItems);
2695
2737
  }
@@ -2776,7 +2818,7 @@ export function convertOpenAICodexResponsesTools(
2776
2818
  const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(baseParameters, strict);
2777
2819
  return {
2778
2820
  type: "function",
2779
- name: tool.name,
2821
+ name: codexToolWireName(tool.name),
2780
2822
  description: tool.description || "",
2781
2823
  parameters,
2782
2824
  ...(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,
@@ -102,7 +102,9 @@ function resolveOpenAIProviderBaseUrl(
102
102
  authCredentialType: "api_key" | "oauth" | undefined,
103
103
  ): string {
104
104
  if (authCredentialType === "oauth") return OPENAI_DEFAULT_BASE_URL;
105
- const envBaseUrl = $inheritedEnv("OPENAI_BASE_URL") ?? $env.OPENAI_BASE_URL?.trim();
105
+ // Trusted sources only: this base URL becomes the request endpoint that carries
106
+ // the OpenAI credential, and `$env` merges the caller's `cwd/.env`.
107
+ const envBaseUrl = $credentialEnv("OPENAI_BASE_URL");
106
108
  const configuredBaseUrl = baseUrl?.trim();
107
109
  if (envBaseUrl && (!configuredBaseUrl || isDefaultOpenAIBaseUrl(configuredBaseUrl))) {
108
110
  return envBaseUrl;
@@ -110,6 +112,14 @@ function resolveOpenAIProviderBaseUrl(
110
112
  return configuredBaseUrl || envBaseUrl || OPENAI_DEFAULT_BASE_URL;
111
113
  }
112
114
 
115
+ /** Test seam: the provider base URL as resolved from trusted env. */
116
+ export function resolveOpenAICompletionsBaseUrlForTest(
117
+ baseUrl: string | undefined,
118
+ authCredentialType: "api_key" | "oauth" | undefined,
119
+ ): string {
120
+ return resolveOpenAIProviderBaseUrl(baseUrl, authCredentialType);
121
+ }
122
+
113
123
  /**
114
124
  * Normalize tool call ID for Mistral.
115
125
  * Mistral requires tool IDs to be exactly 9 alphanumeric characters (a-z, A-Z, 0-9).
@@ -1,11 +1,4 @@
1
- import {
2
- $credentialEnv,
3
- $env,
4
- $inheritedEnv,
5
- extractHttpStatusFromError,
6
- logger,
7
- structuredCloneJSON,
8
- } from "@gajae-code/utils";
1
+ import { $credentialEnv, extractHttpStatusFromError, logger, structuredCloneJSON } from "@gajae-code/utils";
9
2
  import OpenAI from "openai";
10
3
  import type {
11
4
  Tool as OpenAITool,
@@ -159,7 +152,10 @@ function resolveOpenAIProviderBaseUrl(
159
152
  authCredentialType: "api_key" | "oauth" | undefined,
160
153
  ): string {
161
154
  if (authCredentialType === "oauth") return OPENAI_DEFAULT_BASE_URL;
162
- const envBaseUrl = $inheritedEnv("OPENAI_BASE_URL") ?? $env.OPENAI_BASE_URL?.trim();
155
+ // Trusted sources only: this base URL becomes the request endpoint that carries
156
+ // the OpenAI credential, and `$env` merges the caller's `cwd/.env`, so reading it
157
+ // there would let repository content redirect authenticated traffic.
158
+ const envBaseUrl = $credentialEnv("OPENAI_BASE_URL");
163
159
  const configuredBaseUrl = baseUrl?.trim();
164
160
  if (envBaseUrl && (!configuredBaseUrl || isDefaultOpenAIBaseUrl(configuredBaseUrl))) {
165
161
  return envBaseUrl;
@@ -167,6 +163,14 @@ function resolveOpenAIProviderBaseUrl(
167
163
  return configuredBaseUrl || envBaseUrl || OPENAI_DEFAULT_BASE_URL;
168
164
  }
169
165
 
166
+ /** Test seam: the provider base URL as resolved from trusted env. */
167
+ export function resolveOpenAIProviderBaseUrlForTest(
168
+ baseUrl: string | undefined,
169
+ authCredentialType: "api_key" | "oauth" | undefined,
170
+ ): string {
171
+ return resolveOpenAIProviderBaseUrl(baseUrl, authCredentialType);
172
+ }
173
+
170
174
  const OPENAI_RESPONSES_PROGRESS_EVENT_TYPES = new Set([
171
175
  "response.created",
172
176
  "response.output_item.added",
package/src/stream.ts CHANGED
@@ -163,6 +163,7 @@ const serviceProviderMap: Record<string, KeyResolver> = {
163
163
  together: "TOGETHER_API_KEY",
164
164
  zenmux: "ZENMUX_API_KEY",
165
165
  opengateway: "OPENGATEWAY_API_KEY",
166
+ bizrouter: "BIZROUTER_API_KEY",
166
167
  venice: "VENICE_API_KEY",
167
168
  vllm: "VLLM_API_KEY",
168
169
  xiaomi: "XIAOMI_API_KEY",
package/src/types.ts CHANGED
@@ -148,6 +148,7 @@ export type KnownProvider =
148
148
  | "opencode-go"
149
149
  | "opencode-zen"
150
150
  | "opengateway"
151
+ | "bizrouter"
151
152
  | "synthetic"
152
153
  | "cloudflare-ai-gateway"
153
154
  | "huggingface"
@@ -1,4 +1,5 @@
1
1
  import { scheduler } from "node:timers/promises";
2
+ import { claudeCodeVersion } from "../providers/anthropic";
2
3
  import type {
3
4
  CredentialRankingStrategy,
4
5
  UsageAmount,
@@ -17,6 +18,13 @@ const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
17
18
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
18
19
  const MAX_ATTEMPTS = 3;
19
20
  const BASE_RETRY_DELAY_MS = 500;
21
+ /**
22
+ * Ceiling for a server-supplied `Retry-After`. Matches `OPENAI_RETRY_DELAY_CAP_MS`
23
+ * and `fetchWithRetry`'s `DEFAULT_MAX_DELAY_MS`. Without it a hostile or
24
+ * misconfigured endpoint stalls the usage fetch for as long as it likes
25
+ * (`Retry-After: 86400` previously produced a 24h sleep).
26
+ */
27
+ const MAX_RETRY_DELAY_MS = 60_000;
20
28
 
21
29
  const CLAUDE_HEADERS = {
22
30
  accept: "application/json, text/plain, */*",
@@ -24,7 +32,7 @@ const CLAUDE_HEADERS = {
24
32
  "anthropic-beta":
25
33
  "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
26
34
  "content-type": "application/json",
27
- "user-agent": "claude-cli/2.1.63 (external, cli)",
35
+ "user-agent": `claude-cli/${claudeCodeVersion} (external, cli)`,
28
36
  connection: "keep-alive",
29
37
  } as const;
30
38
 
@@ -140,13 +148,23 @@ function isAbortError(error: unknown, signal?: AbortSignal): boolean {
140
148
  return error.name === "AbortError" || error.name === "TimeoutError";
141
149
  }
142
150
 
151
+ /**
152
+ * Honour the server hint but never exceed `MAX_RETRY_DELAY_MS`, and never
153
+ * return a negative/non-finite delay. Keeps the sleep bounded so an abort has
154
+ * an upper bound to fire within.
155
+ */
156
+ function clampRetryDelay(baseline: number, hintMs: number): number {
157
+ const hint = Number.isFinite(hintMs) ? Math.max(0, hintMs) : 0;
158
+ return Math.min(Math.max(baseline, hint), MAX_RETRY_DELAY_MS);
159
+ }
160
+
143
161
  function retryDelayMs(attempt: number, retryAfter: string | null): number {
144
162
  const baseline = BASE_RETRY_DELAY_MS * 2 ** attempt;
145
163
  if (!retryAfter?.trim()) return baseline;
146
164
  const seconds = Number.parseFloat(retryAfter);
147
- if (Number.isFinite(seconds)) return Math.max(baseline, Math.max(0, seconds * 1000));
165
+ if (Number.isFinite(seconds)) return clampRetryDelay(baseline, seconds * 1000);
148
166
  const dateDelay = Date.parse(retryAfter) - Date.now();
149
- return Number.isFinite(dateDelay) ? Math.max(baseline, Math.max(0, dateDelay)) : baseline;
167
+ return Number.isFinite(dateDelay) ? clampRetryDelay(baseline, dateDelay) : baseline;
150
168
  }
151
169
 
152
170
  async function waitBeforeRetry(
@@ -1,3 +1,4 @@
1
+ import { $credentialEnv } from "@gajae-code/utils";
1
2
  import type {
2
3
  CredentialRankingStrategy,
3
4
  UsageFetchContext,
@@ -64,10 +65,20 @@ function isUnsafeGrokBaseUrlOverride(baseUrl?: string): boolean {
64
65
  }
65
66
 
66
67
  function resolveAccessToken(params: UsageFetchParams): string | undefined {
67
- const token = params.credential.accessToken ?? params.credential.apiKey ?? process.env.GROK_CLI_OAUTH_TOKEN;
68
+ // Trusted sources only for the env fallback: this token authenticates the
69
+ // billing/usage call, so whatever can set it decides which account is queried
70
+ // with it. `$env` merges the caller's `cwd/.env` into `process.env`, so
71
+ // reading it there would let repository content supply the credential.
72
+ // Stored credentials keep precedence.
73
+ const token = params.credential.accessToken ?? params.credential.apiKey ?? $credentialEnv("GROK_CLI_OAUTH_TOKEN");
68
74
  return token?.trim() || undefined;
69
75
  }
70
76
 
77
+ /** Test seam: the usage access token as resolved from a credential plus trusted env. */
78
+ export function resolveGrokAccessTokenForTest(params: UsageFetchParams): string | undefined {
79
+ return resolveAccessToken(params);
80
+ }
81
+
71
82
  function buildMonthlyUsageLimit(usage: BillingUsage, nowMs: number): UsageLimit {
72
83
  const usedFraction = usage.monthlyLimit > 0 ? usage.used / usage.monthlyLimit : 0;
73
84
  const percent = usedFraction * 100;
package/src/usage/kimi.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { $env } from "@gajae-code/utils";
1
+ import { $credentialEnv } from "@gajae-code/utils";
2
2
  import type {
3
3
  UsageAmount,
4
4
  UsageFetchContext,
@@ -31,12 +31,26 @@ type KimiUsageRow = {
31
31
  window?: UsageWindow;
32
32
  };
33
33
 
34
+ /**
35
+ * Usage endpoint base, with the environment override resolved from trusted
36
+ * sources only.
37
+ *
38
+ * The result becomes the usage URL that the request sends
39
+ * `Authorization: Bearer <accessToken>` to, so whatever can set it receives the
40
+ * user's Kimi access token. `$env` merges the caller's `cwd/.env`, so reading it
41
+ * there would let repository content collect that token.
42
+ */
34
43
  function normalizeBaseUrl(baseUrl?: string): string {
35
- const envBase = $env.KIMI_CODE_BASE_URL?.trim();
44
+ const envBase = $credentialEnv("KIMI_CODE_BASE_URL");
36
45
  const candidate = baseUrl?.trim() || envBase || DEFAULT_BASE_URL;
37
46
  return candidate.replace(/\/+$/, "");
38
47
  }
39
48
 
49
+ /** Test seam: the usage base URL as resolved from a caller value plus trusted env. */
50
+ export function normalizeKimiUsageBaseUrlForTest(baseUrl?: string): string {
51
+ return normalizeBaseUrl(baseUrl);
52
+ }
53
+
40
54
  function buildUsageUrl(baseUrl: string): string {
41
55
  const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
42
56
  return `${normalized}${USAGE_PATH}`;
@@ -8,7 +8,7 @@
8
8
  * `authStorage.getApiKey("anthropic", sessionId)` first, then pass the result
9
9
  * through {@link buildAnthropicAuthConfig} for header/URL shaping.
10
10
  */
11
- import { $env } from "@gajae-code/utils";
11
+ import { $credentialEnv } from "@gajae-code/utils";
12
12
  import {
13
13
  buildAnthropicHeaders as buildProviderAnthropicHeaders,
14
14
  normalizeAnthropicBaseUrl,
@@ -29,12 +29,20 @@ function normalizeBaseUrl(baseUrl: string | undefined): string | undefined {
29
29
  return trimmed ? trimmed.replace(/\/+$/, "") : undefined;
30
30
  }
31
31
 
32
+ /**
33
+ * Resolve the Anthropic base URL from the environment.
34
+ *
35
+ * Trusted sources only: the result becomes the request URL that carries the
36
+ * Anthropic API key / OAuth token, so whatever can set it can redirect
37
+ * authenticated traffic. `$env` merges the caller's `cwd/.env`, so reading it
38
+ * there would let repository content choose where credentials are sent.
39
+ */
32
40
  export function resolveAnthropicBaseUrlFromEnv(): string | undefined {
33
41
  if (isFoundryEnabled()) {
34
- const foundryBaseUrl = normalizeBaseUrl($env.FOUNDRY_BASE_URL);
42
+ const foundryBaseUrl = normalizeBaseUrl($credentialEnv("FOUNDRY_BASE_URL"));
35
43
  if (foundryBaseUrl) return foundryBaseUrl;
36
44
  }
37
- const anthropicBaseUrl = normalizeBaseUrl($env.ANTHROPIC_BASE_URL);
45
+ const anthropicBaseUrl = normalizeBaseUrl($credentialEnv("ANTHROPIC_BASE_URL"));
38
46
  return anthropicBaseUrl || undefined;
39
47
  }
40
48
 
@@ -1,7 +1,17 @@
1
- import { $env } from "@gajae-code/utils";
1
+ import { $credentialEnv } from "@gajae-code/utils";
2
2
 
3
+ /**
4
+ * Whether Anthropic requests run in Foundry gateway mode.
5
+ *
6
+ * Resolved from trusted environment sources only. Enabling Foundry switches the
7
+ * request base URL and injects TLS client material, so whatever can set this
8
+ * redirects authenticated traffic. `$env` merges the caller's `cwd/.env`, so
9
+ * reading it there would let repository content flip the mode; resolve it the
10
+ * same way the credentials themselves are (launching shell plus GJC/user-owned
11
+ * `.env` files, never the project `.env`).
12
+ */
3
13
  export function isFoundryEnabled(): boolean {
4
- const value = $env.CLAUDE_CODE_USE_FOUNDRY;
14
+ const value = $credentialEnv("CLAUDE_CODE_USE_FOUNDRY");
5
15
  if (!value) return false;
6
16
  const normalized = value.trim().toLowerCase();
7
17
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
@@ -26,6 +26,34 @@ type ErrorWithStatus = {
26
26
 
27
27
  const SENSITIVE_HEADERS = ["authorization", "x-api-key", "api-key", "cookie", "set-cookie", "proxy-authorization"];
28
28
 
29
+ /**
30
+ * Connection-level failure codes, meaning the request never reached the
31
+ * provider and no HTTP status exists. Bun reports the first group for `fetch`;
32
+ * the `E*`/`UND_ERR_*` group comes from Node-style DNS and socket errors.
33
+ *
34
+ * Deliberately excludes aborts and TLS/certificate codes: an abort is a
35
+ * user/watchdog outcome with its own display path, and its message must keep
36
+ * matching the abort normalizers in `modes/utils/abort-message`.
37
+ */
38
+ const TRANSPORT_FAILURE_CODES: ReadonlySet<string> = new Set([
39
+ "ConnectionClosed",
40
+ "ConnectionRefused",
41
+ "ConnectionReset",
42
+ "ConnectionTimeout",
43
+ "FailedToOpenSocket",
44
+ "HTTP2Unsupported",
45
+ "EAI_AGAIN",
46
+ "ECONNREFUSED",
47
+ "ECONNRESET",
48
+ "EHOSTUNREACH",
49
+ "ENETUNREACH",
50
+ "ENOTFOUND",
51
+ "EPIPE",
52
+ "ETIMEDOUT",
53
+ "UND_ERR_CONNECT_TIMEOUT",
54
+ "UND_ERR_SOCKET",
55
+ ]);
56
+
29
57
  /**
30
58
  * Privacy note appended next to a saved raw HTTP request dump. The dump is
31
59
  * sanitized (secrets/thinking redacted) but can still contain prompt content
@@ -88,6 +116,54 @@ export async function appendRawHttpRequestDumpFor400(
88
116
  }
89
117
  }
90
118
 
119
+ /** Origin and path of `value`, dropping query, fragment, and credentials so a
120
+ * key carried in the request URL (Google `?key=`, signed URLs) never lands in
121
+ * a user-visible error string. */
122
+ function redactRequestUrl(value: unknown): string | undefined {
123
+ if (typeof value !== "string" || value.trim().length === 0) return undefined;
124
+ try {
125
+ const url = new URL(value);
126
+ return `${url.origin}${url.pathname}`;
127
+ } catch {
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ function findTransportFailure(error: unknown, depth: number): { code: string; url?: string } | undefined {
133
+ if (!error || typeof error !== "object" || depth > 2) return undefined;
134
+ const info = error as { code?: unknown; path?: unknown; url?: unknown; cause?: unknown };
135
+ if (typeof info.code === "string" && TRANSPORT_FAILURE_CODES.has(info.code)) {
136
+ return { code: info.code, url: redactRequestUrl(info.path) ?? redactRequestUrl(info.url) };
137
+ }
138
+ return findTransportFailure(info.cause, depth + 1);
139
+ }
140
+
141
+ /**
142
+ * Name the failed connection when the request never produced an HTTP status.
143
+ *
144
+ * Bun raises DNS and socket failures as a bare `Error` whose message is a
145
+ * standalone hint ("Was there a typo in the url or port?", "Unable to connect.
146
+ * Is the computer able to access the url?") while the actionable facts live on
147
+ * `code` and `path`. Those properties are dropped when only `message` reaches
148
+ * the assistant message, so a provider outage, a local DNS failure, and a
149
+ * mistyped custom base URL all render as the same context-free sentence.
150
+ * Appending the code and the target URL tells the user which host failed and
151
+ * whether the fault is theirs.
152
+ */
153
+ export function appendTransportFailureContext(
154
+ message: string,
155
+ error: unknown,
156
+ rawRequestDump: RawHttpRequestDump | undefined,
157
+ ): string {
158
+ if (extractHttpStatusFromError(error) !== undefined) return message;
159
+ const failure = findTransportFailure(error, 0);
160
+ if (!failure) return message;
161
+
162
+ const url = failure.url ?? redactRequestUrl(rawRequestDump?.url);
163
+ const context = url ? `transport=${failure.code} url=${url}` : `transport=${failure.code}`;
164
+ return message.includes(context) ? message : `${message} (${context})`;
165
+ }
166
+
91
167
  export async function finalizeErrorMessage(
92
168
  error: unknown,
93
169
  rawRequestDump: RawHttpRequestDump | undefined,
@@ -105,6 +181,7 @@ export async function finalizeErrorMessage(
105
181
  if (isModelUnavailableError(message, error)) {
106
182
  message = `${message}\n\n${formatModelUnavailableGuidance(rawRequestDump)}`;
107
183
  }
184
+ message = appendTransportFailureContext(message, error, rawRequestDump);
108
185
  return appendRawHttpRequestDumpFor400(message, error, rawRequestDump);
109
186
  }
110
187
 
@@ -19,7 +19,7 @@ function normalizeIdleTimeoutMs(value: string | undefined, fallback: number): nu
19
19
  /**
20
20
  * Returns the idle timeout used for provider streaming transports.
21
21
  *
22
- * `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` is accepted as a backward-compatible alias.
22
+ * `GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS` is honored first; `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` is a backward-compatible alias.
23
23
  * Set `PI_STREAM_IDLE_TIMEOUT_MS=0` to disable the watchdog.
24
24
  *
25
25
  * Providers that legitimately stream much slower than the global default can pass
@@ -27,17 +27,20 @@ function normalizeIdleTimeoutMs(value: string | undefined, fallback: number): nu
27
27
  * Caller options still take precedence; env overrides still trump the fallback.
28
28
  */
29
29
  export function getStreamIdleTimeoutMs(fallbackMs: number = DEFAULT_STREAM_IDLE_TIMEOUT_MS): number | undefined {
30
- return normalizeIdleTimeoutMs($env.PI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS, fallbackMs);
30
+ return normalizeIdleTimeoutMs(
31
+ $env.GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS,
32
+ fallbackMs,
33
+ );
31
34
  }
32
35
 
33
36
  /**
34
37
  * Returns the idle timeout used for OpenAI-family streaming transports.
35
38
  *
36
- * Set `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS=0` to disable the watchdog.
39
+ * Honors `GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS` first (`PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` is the legacy alias). Set `=0` to disable.
37
40
  */
38
41
  export function getOpenAIStreamIdleTimeoutMs(): number | undefined {
39
42
  return normalizeIdleTimeoutMs(
40
- $env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_STREAM_IDLE_TIMEOUT_MS,
43
+ $env.GJC_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_OPENAI_STREAM_IDLE_TIMEOUT_MS ?? $env.PI_STREAM_IDLE_TIMEOUT_MS,
41
44
  DEFAULT_STREAM_IDLE_TIMEOUT_MS,
42
45
  );
43
46
  }
@@ -173,8 +176,6 @@ export async function* iterateWithIdleTimeout<T>(
173
176
  }
174
177
  }
175
178
 
176
- const nextResultPromise = withRacy(iterator.next());
177
-
178
179
  const racers: Array<
179
180
  Promise<
180
181
  | { kind: "next"; result: IteratorResult<T> }
@@ -182,7 +183,7 @@ export async function* iterateWithIdleTimeout<T>(
182
183
  | { kind: "timeout" }
183
184
  | { kind: "abort" }
184
185
  >
185
- > = [nextResultPromise];
186
+ > = [];
186
187
 
187
188
  let timer: NodeJS.Timeout | undefined;
188
189
  let resolveTimeout: ((value: { kind: "timeout" }) => void) | undefined;
@@ -207,6 +208,13 @@ export async function* iterateWithIdleTimeout<T>(
207
208
  racers.push(promise);
208
209
  }
209
210
 
211
+ // Arm timeout/abort races before asking the source for its next item. A
212
+ // periodic keepalive iterator commonly registers its own timer inside
213
+ // `next()`; registering that first lets equal-deadline keepalives win every
214
+ // race and extend the idle window forever. Already-buffered items still
215
+ // settle as microtasks before a 0ms watchdog.
216
+ racers.unshift(withRacy(iterator.next()));
217
+
210
218
  try {
211
219
  const outcome = await Promise.race(racers);
212
220
  if (outcome.kind === "abort") {