@gajae-code/ai 0.12.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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";
@@ -94,6 +95,49 @@ export function formatModelUnavailableGuidance(dump: RawHttpRequestDump | undefi
94
95
  ].join("\n");
95
96
  }
96
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
+
97
141
  export async function appendRawHttpRequestDumpFor400(
98
142
  message: string,
99
143
  error: unknown,
@@ -105,10 +149,12 @@ export async function appendRawHttpRequestDumpFor400(
105
149
 
106
150
  const sanitizedDump = sanitizeDump(dump);
107
151
  const fileName = `${Date.now()}-${Bun.hash(JSON.stringify(sanitizedDump)).toString(36)}.json`;
108
- const filePath = path.join(getLogsDir(), "http-400-requests", fileName);
152
+ const dumpDir = httpRequestDumpDir();
153
+ const filePath = path.join(dumpDir, fileName);
109
154
 
110
155
  try {
111
156
  await Bun.write(filePath, `${JSON.stringify(sanitizedDump, null, 2)}\n`);
157
+ await pruneHttpRequestDumps(dumpDir);
112
158
  return `${message}\nraw-http-request=${filePath}\n${RAW_HTTP_REQUEST_PRIVACY_NOTE}`;
113
159
  } catch (writeError) {
114
160
  const writeMessage = writeError instanceof Error ? writeError.message : String(writeError);
@@ -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;
@@ -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) {
@@ -1,4 +1,4 @@
1
- import { $flag } from "@gajae-code/utils";
1
+ import { $pickflag } from "@gajae-code/utils";
2
2
  import { upgradeJsonSchemaTo202012 } from "./draft";
3
3
  import { tryEnforceStrictSchema } from "./normalize";
4
4
 
@@ -11,7 +11,7 @@ import { tryEnforceStrictSchema } from "./normalize";
11
11
  * see `openai-completions`, `openai-responses`, `OpenAI code provider-responses`, and
12
12
  * the strict candidate selection in `anthropic`.
13
13
  */
14
- export const NO_STRICT = $flag("PI_NO_STRICT");
14
+ export const NO_STRICT = $pickflag("GJC_NO_STRICT", "PI_NO_STRICT");
15
15
 
16
16
  /**
17
17
  * Consolidated helper for OpenAI-style strict schema enforcement.
@@ -175,7 +175,8 @@ export function isForcedToolChoiceUnsupportedError(error: unknown, sentForcedToo
175
175
  message,
176
176
  ) ||
177
177
  /forces?\s+tool\s+use.*?(not\s+compatible|incompatible|not\s+supported)/is.test(message) ||
178
- /does\s+not\s+support\s+forced\s+tool[_\s-]?choices?/is.test(message)
178
+ /does\s+not\s+support\s+forced\s+tool[_\s-]?choices?/is.test(message) ||
179
+ /tool[_\s-]?choices?\s+['"`][^'"`\r\n]+['"`]\s+not\s+found\s+in\s+['"`]tools['"`]\s+parameter\b/is.test(message)
179
180
  );
180
181
  }
181
182