@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,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,
@@ -44,7 +37,6 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
44
37
  import { transportFailureFacts } from "../utils/fallback-transport";
45
38
  import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
46
39
  import {
47
- createWatchdog,
48
40
  getOpenAIStreamIdleTimeoutMs,
49
41
  getStreamFirstEventTimeoutMs,
50
42
  iterateWithIdleTimeout,
@@ -159,7 +151,10 @@ function resolveOpenAIProviderBaseUrl(
159
151
  authCredentialType: "api_key" | "oauth" | undefined,
160
152
  ): string {
161
153
  if (authCredentialType === "oauth") return OPENAI_DEFAULT_BASE_URL;
162
- const envBaseUrl = $inheritedEnv("OPENAI_BASE_URL") ?? $env.OPENAI_BASE_URL?.trim();
154
+ // Trusted sources only: this base URL becomes the request endpoint that carries
155
+ // the OpenAI credential, and `$env` merges the caller's `cwd/.env`, so reading it
156
+ // there would let repository content redirect authenticated traffic.
157
+ const envBaseUrl = $credentialEnv("OPENAI_BASE_URL");
163
158
  const configuredBaseUrl = baseUrl?.trim();
164
159
  if (envBaseUrl && (!configuredBaseUrl || isDefaultOpenAIBaseUrl(configuredBaseUrl))) {
165
160
  return envBaseUrl;
@@ -167,6 +162,14 @@ function resolveOpenAIProviderBaseUrl(
167
162
  return configuredBaseUrl || envBaseUrl || OPENAI_DEFAULT_BASE_URL;
168
163
  }
169
164
 
165
+ /** Test seam: the provider base URL as resolved from trusted env. */
166
+ export function resolveOpenAIProviderBaseUrlForTest(
167
+ baseUrl: string | undefined,
168
+ authCredentialType: "api_key" | "oauth" | undefined,
169
+ ): string {
170
+ return resolveOpenAIProviderBaseUrl(baseUrl, authCredentialType);
171
+ }
172
+
170
173
  const OPENAI_RESPONSES_PROGRESS_EVENT_TYPES = new Set([
171
174
  "response.created",
172
175
  "response.output_item.added",
@@ -261,7 +264,6 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
261
264
  );
262
265
  let rawRequestDump: RawHttpRequestDump | undefined;
263
266
  const abortTracker = createAbortSourceTracker(options?.signal);
264
- const firstEventTimeoutAbortError = new Error(OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE);
265
267
  const { requestAbortController, requestSignal } = abortTracker;
266
268
 
267
269
  try {
@@ -333,10 +335,8 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
333
335
  });
334
336
  const firstEventFallbackMs =
335
337
  model.provider === "alibaba-token-plan" ? ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MS : undefined;
336
- const firstEventWatchdog = createWatchdog(
337
- options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs),
338
- () => abortTracker.abortLocally(firstEventTimeoutAbortError),
339
- );
338
+ const firstEventTimeoutMs =
339
+ options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs);
340
340
  if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
341
341
  stream.push({ type: "start", partial: output });
342
342
 
@@ -344,9 +344,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
344
344
  await processResponsesStream(
345
345
  iterateWithIdleTimeout(openaiStream, {
346
346
  idleTimeoutMs,
347
- watchdog: firstEventWatchdog,
347
+ firstItemTimeoutMs: firstEventTimeoutMs,
348
+ firstItemErrorMessage: OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
348
349
  errorMessage: "OpenAI responses stream stalled while waiting for the next event",
349
350
  onIdle: () => requestAbortController.abort(),
351
+ onFirstItemTimeout: () => requestAbortController.abort(),
350
352
  abortSignal: options?.signal,
351
353
  isProgressItem: isOpenAIResponsesProgressEvent,
352
354
  }),
@@ -385,11 +387,11 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
385
387
  stream.end();
386
388
  } catch (error) {
387
389
  for (const block of output.content) delete (block as { index?: number }).index;
388
- const firstEventTimeoutError = abortTracker.getLocalAbortReason();
390
+ const localAbortReason = abortTracker.getLocalAbortReason();
389
391
  output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
390
- output.errorStatus = extractHttpStatusFromError(error);
391
- output.transportFailure = transportFailureFacts(error);
392
- output.errorMessage = firstEventTimeoutError?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
392
+ output.errorStatus = extractHttpStatusFromError(localAbortReason ?? error);
393
+ output.transportFailure = transportFailureFacts(localAbortReason ?? error);
394
+ output.errorMessage = localAbortReason?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
393
395
  output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
394
396
  // Explicitly mark the poisoned-history rejection so the shared
395
397
  // `invalid_prompt` contract is present even when the SDK error surfaces
@@ -10,6 +10,7 @@
10
10
  * lazy wrappers below), so this file IS the main streaming path's provider
11
11
  * loader: heavy SDKs stay out of the CLI startup parse graph.
12
12
  */
13
+
13
14
  import type {
14
15
  Api,
15
16
  AssistantMessage,
@@ -21,7 +22,13 @@ import type {
21
22
  } from "../types";
22
23
  import { type AbortSourceTracker, createAbortSourceTracker } from "../utils/abort";
23
24
  import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-stream";
24
- import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
25
+ import { transportFailureFacts } from "../utils/fallback-transport";
26
+ import {
27
+ FirstEventTimeoutError,
28
+ getStreamFirstEventTimeoutMs,
29
+ getStreamIdleTimeoutMs,
30
+ iterateWithIdleTimeout,
31
+ } from "../utils/idle-iterator";
25
32
  import type { BedrockOptions } from "./amazon-bedrock";
26
33
  import type { AnthropicOptions } from "./anthropic";
27
34
  import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses";
@@ -229,7 +236,8 @@ function forwardStream<TApi extends Api>(
229
236
  errorMessage: LAZY_STREAM_IDLE_TIMEOUT_ERROR,
230
237
  firstItemErrorMessage: LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR,
231
238
  onIdle: () => abortTracker.abortLocally(new Error(LAZY_STREAM_IDLE_TIMEOUT_ERROR)),
232
- onFirstItemTimeout: () => abortTracker.abortLocally(new Error(LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR)),
239
+ onFirstItemTimeout: () =>
240
+ abortTracker.abortLocally(new FirstEventTimeoutError(LAZY_STREAM_FIRST_EVENT_TIMEOUT_ERROR)),
233
241
  abortSignal: options.signal,
234
242
  // The synthetic `start` event is yielded immediately by every provider before
235
243
  // the upstream model has emitted any tokens. Treating it as the first "real"
@@ -261,6 +269,7 @@ function createLazyLoadErrorMessage<TApi extends Api>(
261
269
  error: unknown,
262
270
  stopReason: Extract<AssistantMessage["stopReason"], "aborted" | "error"> = "error",
263
271
  ): AssistantMessage {
272
+ const transportFailure = transportFailureFacts(error);
264
273
  return {
265
274
  role: "assistant",
266
275
  content: [],
@@ -278,6 +287,7 @@ function createLazyLoadErrorMessage<TApi extends Api>(
278
287
  stopReason,
279
288
  errorMessage:
280
289
  stopReason === "aborted" ? "Request was aborted" : error instanceof Error ? error.message : String(error),
290
+ ...(transportFailure ? { transportFailure } : {}),
281
291
  timestamp: Date.now(),
282
292
  };
283
293
  }
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,
@@ -31,7 +32,7 @@ const CLAUDE_HEADERS = {
31
32
  "anthropic-beta":
32
33
  "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
33
34
  "content-type": "application/json",
34
- "user-agent": "claude-cli/2.1.63 (external, cli)",
35
+ "user-agent": `claude-cli/${claudeCodeVersion} (external, cli)`,
35
36
  connection: "keep-alive",
36
37
  } as const;
37
38
 
@@ -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
 
@@ -5,6 +5,9 @@ export interface FallbackTrigger {
5
5
  retryAfterMs?: number;
6
6
  }
7
7
 
8
+ /** Stable code for streams that time out before producing semantic progress. */
9
+ export const STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE = "stream_first_event_timeout";
10
+
8
11
  export type TransportHeaders = Headers | Record<string, string | undefined>;
9
12
 
10
13
  /**
@@ -185,7 +188,8 @@ export function transportFailureFacts(
185
188
  !isQuotaCode(normalizedCode) &&
186
189
  !isAuthCode(normalizedCode) &&
187
190
  !isRateLimitCode(normalizedCode) &&
188
- !isContextOverflowCode(normalizedCode)
191
+ !isContextOverflowCode(normalizedCode) &&
192
+ normalizedCode !== STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE
189
193
  ) {
190
194
  return undefined;
191
195
  }
@@ -256,14 +260,17 @@ export function classifyFallbackTrigger(
256
260
  parseRetryAfterMilliseconds(headers?.get("retry-after-ms") ?? null) ??
257
261
  parseRetryAfterSeconds(headers?.get("retry-after") ?? null);
258
262
  const code = (facts.openaiErrorCode ?? facts.anthropicErrorType ?? facts.providerCode)?.toLowerCase();
259
- const triggerClass: FallbackTriggerClass = isQuotaCode(code)
260
- ? "quota"
261
- : facts.status === 401 || facts.status === 403 || isAuthCode(code)
262
- ? "auth"
263
- : facts.status === 429 || isRateLimitCode(code)
264
- ? "rate_limit"
265
- : facts.status !== undefined && facts.status >= 500 && facts.status <= 599
266
- ? "server"
267
- : "other";
263
+ const triggerClass: FallbackTriggerClass =
264
+ code === STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE
265
+ ? "server"
266
+ : isQuotaCode(code)
267
+ ? "quota"
268
+ : facts.status === 401 || facts.status === 403 || isAuthCode(code)
269
+ ? "auth"
270
+ : facts.status === 429 || isRateLimitCode(code)
271
+ ? "rate_limit"
272
+ : facts.status !== undefined && facts.status >= 500 && facts.status <= 599
273
+ ? "server"
274
+ : "other";
268
275
  return retryAfterMs === undefined ? { class: triggerClass } : { class: triggerClass, retryAfterMs };
269
276
  }
@@ -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";
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
3
  import { APP_NAME, extractHttpStatusFromError, getLogsDir } from "@gajae-code/utils";
3
4
  import { isCopilotTransientModelError } from "./retry.js";
@@ -26,6 +27,34 @@ type ErrorWithStatus = {
26
27
 
27
28
  const SENSITIVE_HEADERS = ["authorization", "x-api-key", "api-key", "cookie", "set-cookie", "proxy-authorization"];
28
29
 
30
+ /**
31
+ * Connection-level failure codes, meaning the request never reached the
32
+ * provider and no HTTP status exists. Bun reports the first group for `fetch`;
33
+ * the `E*`/`UND_ERR_*` group comes from Node-style DNS and socket errors.
34
+ *
35
+ * Deliberately excludes aborts and TLS/certificate codes: an abort is a
36
+ * user/watchdog outcome with its own display path, and its message must keep
37
+ * matching the abort normalizers in `modes/utils/abort-message`.
38
+ */
39
+ const TRANSPORT_FAILURE_CODES: ReadonlySet<string> = new Set([
40
+ "ConnectionClosed",
41
+ "ConnectionRefused",
42
+ "ConnectionReset",
43
+ "ConnectionTimeout",
44
+ "FailedToOpenSocket",
45
+ "HTTP2Unsupported",
46
+ "EAI_AGAIN",
47
+ "ECONNREFUSED",
48
+ "ECONNRESET",
49
+ "EHOSTUNREACH",
50
+ "ENETUNREACH",
51
+ "ENOTFOUND",
52
+ "EPIPE",
53
+ "ETIMEDOUT",
54
+ "UND_ERR_CONNECT_TIMEOUT",
55
+ "UND_ERR_SOCKET",
56
+ ]);
57
+
29
58
  /**
30
59
  * Privacy note appended next to a saved raw HTTP request dump. The dump is
31
60
  * sanitized (secrets/thinking redacted) but can still contain prompt content
@@ -66,6 +95,49 @@ export function formatModelUnavailableGuidance(dump: RawHttpRequestDump | undefi
66
95
  ].join("\n");
67
96
  }
68
97
 
98
+ /**
99
+ * Cap on retained HTTP 400 request dumps.
100
+ *
101
+ * Each dump carries the full sanitized request body, so they are large: a
102
+ * developer machine accumulated 27,249 files totalling 7.0 GB, averaging 264 KB
103
+ * each, because nothing ever removed them. The rotating application log already
104
+ * bounds itself (`maxSize: 10m`, `maxFiles: 5`); these diagnostics get the same
105
+ * treatment so the newest failures stay available without unbounded growth.
106
+ */
107
+ const MAX_RETAINED_DUMPS = 50;
108
+
109
+ /** Directory holding the retained HTTP 400 dumps. */
110
+ export function httpRequestDumpDir(): string {
111
+ return path.join(getLogsDir(), "http-400-requests");
112
+ }
113
+
114
+ /**
115
+ * Drop the oldest dumps beyond the cap. Best-effort: diagnostics must never turn
116
+ * a request failure into a second failure, so every step swallows its error.
117
+ *
118
+ * File names are `${Date.now()}-${hash}.json`, so a lexical sort is chronological
119
+ * for the millisecond timestamps this writer produces.
120
+ */
121
+ export async function pruneHttpRequestDumps(dir: string = httpRequestDumpDir()): Promise<number> {
122
+ const entries = await fs.readdir(dir).catch(() => undefined);
123
+ if (!entries) return 0;
124
+
125
+ const dumps = entries.filter(name => name.endsWith(".json")).sort();
126
+ if (dumps.length <= MAX_RETAINED_DUMPS) return 0;
127
+
128
+ let removed = 0;
129
+ for (const name of dumps.slice(0, dumps.length - MAX_RETAINED_DUMPS)) {
130
+ if (
131
+ await fs.rm(path.join(dir, name), { force: true }).then(
132
+ () => true,
133
+ () => false,
134
+ )
135
+ )
136
+ removed++;
137
+ }
138
+ return removed;
139
+ }
140
+
69
141
  export async function appendRawHttpRequestDumpFor400(
70
142
  message: string,
71
143
  error: unknown,
@@ -77,10 +149,12 @@ export async function appendRawHttpRequestDumpFor400(
77
149
 
78
150
  const sanitizedDump = sanitizeDump(dump);
79
151
  const fileName = `${Date.now()}-${Bun.hash(JSON.stringify(sanitizedDump)).toString(36)}.json`;
80
- const filePath = path.join(getLogsDir(), "http-400-requests", fileName);
152
+ const dumpDir = httpRequestDumpDir();
153
+ const filePath = path.join(dumpDir, fileName);
81
154
 
82
155
  try {
83
156
  await Bun.write(filePath, `${JSON.stringify(sanitizedDump, null, 2)}\n`);
157
+ await pruneHttpRequestDumps(dumpDir);
84
158
  return `${message}\nraw-http-request=${filePath}\n${RAW_HTTP_REQUEST_PRIVACY_NOTE}`;
85
159
  } catch (writeError) {
86
160
  const writeMessage = writeError instanceof Error ? writeError.message : String(writeError);
@@ -88,6 +162,54 @@ export async function appendRawHttpRequestDumpFor400(
88
162
  }
89
163
  }
90
164
 
165
+ /** Origin and path of `value`, dropping query, fragment, and credentials so a
166
+ * key carried in the request URL (Google `?key=`, signed URLs) never lands in
167
+ * a user-visible error string. */
168
+ function redactRequestUrl(value: unknown): string | undefined {
169
+ if (typeof value !== "string" || value.trim().length === 0) return undefined;
170
+ try {
171
+ const url = new URL(value);
172
+ return `${url.origin}${url.pathname}`;
173
+ } catch {
174
+ return undefined;
175
+ }
176
+ }
177
+
178
+ function findTransportFailure(error: unknown, depth: number): { code: string; url?: string } | undefined {
179
+ if (!error || typeof error !== "object" || depth > 2) return undefined;
180
+ const info = error as { code?: unknown; path?: unknown; url?: unknown; cause?: unknown };
181
+ if (typeof info.code === "string" && TRANSPORT_FAILURE_CODES.has(info.code)) {
182
+ return { code: info.code, url: redactRequestUrl(info.path) ?? redactRequestUrl(info.url) };
183
+ }
184
+ return findTransportFailure(info.cause, depth + 1);
185
+ }
186
+
187
+ /**
188
+ * Name the failed connection when the request never produced an HTTP status.
189
+ *
190
+ * Bun raises DNS and socket failures as a bare `Error` whose message is a
191
+ * standalone hint ("Was there a typo in the url or port?", "Unable to connect.
192
+ * Is the computer able to access the url?") while the actionable facts live on
193
+ * `code` and `path`. Those properties are dropped when only `message` reaches
194
+ * the assistant message, so a provider outage, a local DNS failure, and a
195
+ * mistyped custom base URL all render as the same context-free sentence.
196
+ * Appending the code and the target URL tells the user which host failed and
197
+ * whether the fault is theirs.
198
+ */
199
+ export function appendTransportFailureContext(
200
+ message: string,
201
+ error: unknown,
202
+ rawRequestDump: RawHttpRequestDump | undefined,
203
+ ): string {
204
+ if (extractHttpStatusFromError(error) !== undefined) return message;
205
+ const failure = findTransportFailure(error, 0);
206
+ if (!failure) return message;
207
+
208
+ const url = failure.url ?? redactRequestUrl(rawRequestDump?.url);
209
+ const context = url ? `transport=${failure.code} url=${url}` : `transport=${failure.code}`;
210
+ return message.includes(context) ? message : `${message} (${context})`;
211
+ }
212
+
91
213
  export async function finalizeErrorMessage(
92
214
  error: unknown,
93
215
  rawRequestDump: RawHttpRequestDump | undefined,
@@ -105,6 +227,7 @@ export async function finalizeErrorMessage(
105
227
  if (isModelUnavailableError(message, error)) {
106
228
  message = `${message}\n\n${formatModelUnavailableGuidance(rawRequestDump)}`;
107
229
  }
230
+ message = appendTransportFailureContext(message, error, rawRequestDump);
108
231
  return appendRawHttpRequestDumpFor400(message, error, rawRequestDump);
109
232
  }
110
233
 
@@ -1,4 +1,5 @@
1
1
  import { $env } from "@gajae-code/utils";
2
+ import { STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE } from "./fallback-transport";
2
3
 
3
4
  const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;
4
5
  const DEFAULT_STREAM_FIRST_EVENT_TIMEOUT_MS = 100_000;
@@ -66,6 +67,14 @@ export function getStreamFirstEventTimeoutMs(
66
67
  }
67
68
 
68
69
  export type Watchdog = NodeJS.Timeout | undefined;
70
+ export class FirstEventTimeoutError extends Error {
71
+ readonly providerCode = STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE;
72
+
73
+ constructor(message: string) {
74
+ super(message);
75
+ this.name = "FirstEventTimeoutError";
76
+ }
77
+ }
69
78
 
70
79
  const dummyWatchdog = setTimeout(() => {}, 1);
71
80
  clearTimeout(dummyWatchdog);
@@ -228,9 +237,9 @@ export async function* iterateWithIdleTimeout<T>(
228
237
  options.onFirstItemTimeout?.();
229
238
  }
230
239
  closeIterator();
231
- throw new Error(
232
- !awaitingFirstItem ? options.errorMessage : (options.firstItemErrorMessage ?? options.errorMessage),
233
- );
240
+ throw awaitingFirstItem
241
+ ? new FirstEventTimeoutError(options.firstItemErrorMessage ?? options.errorMessage)
242
+ : new Error(options.errorMessage);
234
243
  }
235
244
  if (outcome.kind === "error") {
236
245
  throw outcome.error;
@@ -0,0 +1,15 @@
1
+ /** BizRouter login flow (API key paste, validated via /v1/models). */
2
+ import { createApiKeyLogin } from "./api-key-login";
3
+
4
+ export const loginBizRouter = createApiKeyLogin({
5
+ providerLabel: "BizRouter",
6
+ authUrl: "https://bizrouter.ai/settings/keys",
7
+ instructions: "Create or copy your BizRouter API key",
8
+ promptMessage: "Paste your BizRouter API key",
9
+ placeholder: "sk-br-v1-...",
10
+ validation: {
11
+ kind: "models-endpoint",
12
+ provider: "BizRouter",
13
+ modelsUrl: "https://api.bizrouter.ai/v1/models",
14
+ },
15
+ });
@@ -240,6 +240,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
240
240
  name: "ZenMux",
241
241
  available: true,
242
242
  },
243
+ {
244
+ id: "bizrouter",
245
+ name: "BizRouter",
246
+ available: true,
247
+ },
243
248
  {
244
249
  id: "opengateway",
245
250
  name: "OpenGateway by Sionic AI",
@@ -391,6 +396,7 @@ export async function refreshOAuthToken(
391
396
  case "vercel-ai-gateway":
392
397
  case "qwen-portal":
393
398
  case "zenmux":
399
+ case "bizrouter":
394
400
  case "opengateway":
395
401
  case "vllm":
396
402
  // API keys / static bearer tokens don't expire, return as-is
@@ -7,7 +7,7 @@ import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
9
  import { scheduler } from "node:timers/promises";
10
- import { $env, getAgentDir, isEnoent } from "@gajae-code/utils";
10
+ import { $pickCredentialEnv, getAgentDir, isEnoent } from "@gajae-code/utils";
11
11
  import packageJson from "../../../package.json" with { type: "json" };
12
12
  import type { OAuthController, OAuthCredentials } from "./types";
13
13
 
@@ -38,8 +38,23 @@ interface TokenResponse {
38
38
  interval?: number;
39
39
  }
40
40
 
41
+ /**
42
+ * OAuth host for the device flow, from trusted environment sources only.
43
+ *
44
+ * This host receives the device-authorization request, the authorization-code
45
+ * exchange, and the refresh call that carries the existing refresh token, so
46
+ * whatever can set it can collect the user's Kimi credentials. `$env` merges the
47
+ * caller's `cwd/.env`, so reading it there would let repository content redirect
48
+ * the login flow. Resolve it the same way the credentials themselves are:
49
+ * launching shell plus GJC/user-owned `.env` files, never the project `.env`.
50
+ */
41
51
  function resolveOAuthHost(): string {
42
- return $env.KIMI_CODE_OAUTH_HOST || $env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST;
52
+ return $pickCredentialEnv("KIMI_CODE_OAUTH_HOST", "KIMI_OAUTH_HOST") || DEFAULT_OAUTH_HOST;
53
+ }
54
+
55
+ /** Test seam: the OAuth host as resolved from trusted env. */
56
+ export function resolveKimiOAuthHostForTest(): string {
57
+ return resolveOAuthHost();
43
58
  }
44
59
 
45
60
  function formatDeviceModel(system: string, release: string, arch: string): string {
@@ -186,13 +186,32 @@ async function httpEmailLogin(ctrl: OAuthController): Promise<OAuthCredentials>
186
186
  *
187
187
  * No browser/manual token paste fallback is used.
188
188
  */
189
+ /**
190
+ * Whether the operator disabled borrowing a token from the native macOS app.
191
+ *
192
+ * `GJC_AUTH_NO_BORROW` is the documented name; `PI_AUTH_NO_BORROW` is the legacy
193
+ * one that was the only name actually read.
194
+ */
195
+ function authBorrowDisabled(): boolean {
196
+ return Boolean($env.GJC_AUTH_NO_BORROW || $env.PI_AUTH_NO_BORROW);
197
+ }
198
+
199
+ /** Test seam: the resolved native-app borrowing opt-out. */
200
+ export function authBorrowDisabledForTest(): boolean {
201
+ return authBorrowDisabled();
202
+ }
203
+
189
204
  export async function loginPerplexity(ctrl: OAuthController): Promise<OAuthCredentials> {
190
205
  if (!ctrl.onPrompt) {
191
206
  throw new Error("Perplexity login requires onPrompt callback");
192
207
  }
193
208
 
194
- // Path 1: Native macOS app JWT (skip if PI_AUTH_NO_BORROW=1)
195
- if (!$env.PI_AUTH_NO_BORROW) {
209
+ // Path 1: Native macOS app JWT, skipped when the operator opts out.
210
+ //
211
+ // Presence-based on purpose: this is a privacy opt-out, so any set value must
212
+ // disable borrowing. A boolean contract would let `GJC_AUTH_NO_BORROW=0`
213
+ // silently re-enable reading a token out of another application.
214
+ if (!authBorrowDisabled()) {
196
215
  ctrl.onProgress?.("Checking for Perplexity desktop app...");
197
216
  const nativeJwt = await extractFromNativeApp();
198
217
  if (nativeJwt) {
@@ -11,6 +11,7 @@ export type OAuthCredentials = {
11
11
  export type OAuthProvider =
12
12
  | "alibaba-token-plan"
13
13
  | "anthropic"
14
+ | "bizrouter"
14
15
  | "cerebras"
15
16
  | "cloudflare-ai-gateway"
16
17
  | "cursor"