@gajae-code/ai 0.18.8 → 0.18.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.18.9] - 2026-10-11
6
+
7
+ ### Fixed
8
+
9
+ - Exclude Claude Code and OAuth beta headers from API-key requests so subscription-provided API credits can be used, while preserving OAuth headers and feature betas.
10
+
11
+ - Anthropic API-key requests no longer send the Claude Code `X-App: cli` marker, and Anthropic API-key model discovery (`/v1/models`) no longer sends the Claude Code beta list (`claude-code-20250219`, `oauth-2025-04-20`, …). Anthropic classified requests carrying these markers as Claude Code usage, which excluded them from API credit grants such as the Max plan's monthly API credit. OAuth requests are unchanged.
12
+
13
+ - Finalize all outstanding SQLite auth statements on close, including usage-fetch leases, so Windows releases database file handles immediately.
14
+
15
+ - Auth broker credential requests do not follow redirects, so a 307 or 308 cannot replay the JSON body to another host.
16
+
17
+ - Preserve bounded terminal errors when Codex Responses times out mid-tool-call by dropping unvalidated partial arguments, retaining the call identity, and reporting `request_timeout`.
18
+
19
+ - A project `.env` can no longer add Foundry request headers through `ANTHROPIC_CUSTOM_HEADERS`.
20
+
21
+ - A project `.env` can no longer choose the Foundry CA or mTLS client certificate, including when the declaration uses `$` or backticks, and later deleting that file or changing directory does not turn the loaded value into an operator setting.
22
+
23
+ - A tokenless auth gateway rejects data requests whose Host header is not the loopback bind.
24
+
25
+ - HTTP 400 request dumps redact `x-goog-api-key` and `cf-aig-authorization` before the dump JSON is written, and the dump file is created owner-readable only.
26
+
27
+ - Finalize cached SQLite statements when closing the shared model cache so Windows releases its database file handle before temporary roots are removed.
28
+
5
29
  ## [0.18.8] - 2026-10-08
6
30
 
7
31
  ### Added
@@ -25,8 +25,11 @@ export declare function isLoopbackHostname(hostname: string): boolean;
25
25
  */
26
26
  export declare function assertAuthenticatedOrLoopback(bind: ParsedBind, bearerTokenCount: number, serverName: string): void;
27
27
  /**
28
- * Tokenless loopback brokers accept a request only when its Host header is the
29
- * bound hostname and port. A DNS-rebound same-origin GET sends the attacker's
30
- * name and no Origin header.
28
+ * Tokenless loopback servers accept a request only when its Host header is the
29
+ * bound hostname and effective port. Default-port forms that stay allowed:
30
+ * a Host with no port, or an explicit :80, on an HTTP listener on port 80;
31
+ * an explicit :443 on a listener on port 443. Any other omitted port is
32
+ * rejected. A DNS-rebound same-origin GET sends the attacker's name and no
33
+ * Origin header.
31
34
  */
32
35
  export declare function hostHeaderMatchesBind(hostHeader: string | null, bind: ParsedBind): boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.18.8",
4
+ "version": "0.18.9",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -41,8 +41,8 @@
41
41
  "@agentclientprotocol/sdk": "1.3.0",
42
42
  "@anthropic-ai/sdk": "^0.94.0",
43
43
  "@bufbuild/protobuf": "^2.12.0",
44
- "@gajae-code/natives": "0.18.8",
45
- "@gajae-code/utils": "0.18.8",
44
+ "@gajae-code/natives": "0.18.9",
45
+ "@gajae-code/utils": "0.18.9",
46
46
  "openai": "^6.36.0",
47
47
  "partial-json": "^0.1.7",
48
48
  "zod": "4.4.3"
@@ -421,6 +421,7 @@ export class AuthBrokerClient {
421
421
  headers,
422
422
  body: payload,
423
423
  signal,
424
+ redirect: "error",
424
425
  });
425
426
  if (!response.ok && response.status !== 304) {
426
427
  const text = await response.text();
@@ -39,7 +39,7 @@ import type {
39
39
  SimpleStreamOptions,
40
40
  } from "../types";
41
41
  import { beginAttempt, classifyFallbackTrigger } from "../utils/fallback-transport";
42
- import { assertAuthenticatedOrLoopback, parseBind } from "../utils/parse-bind";
42
+ import { assertAuthenticatedOrLoopback, hostHeaderMatchesBind, parseBind } from "../utils/parse-bind";
43
43
  import { REPETITION_GUARD_ERROR_CODE } from "../utils/stream-repetition-guard";
44
44
  import {
45
45
  captureRequestHeaders,
@@ -1281,6 +1281,7 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
1281
1281
  assertAuthenticatedOrLoopback(bind, tokens.size, "auth-gateway");
1282
1282
  const version = opts.version;
1283
1283
 
1284
+ let listenPort = bind.port;
1284
1285
  const server = Bun.serve({
1285
1286
  hostname: bind.hostname,
1286
1287
  port: bind.port,
@@ -1297,6 +1298,19 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
1297
1298
  });
1298
1299
  return json(403, { error: "no-auth rejects requests carrying Origin" });
1299
1300
  }
1301
+ if (
1302
+ tokens.size === 0 &&
1303
+ !(req.method === "GET" && pathname === "/healthz") &&
1304
+ !hostHeaderMatchesBind(req.headers.get("host"), { hostname: bind.hostname, port: listenPort })
1305
+ ) {
1306
+ logger.info("auth-gateway no-auth host rejected", {
1307
+ method: req.method,
1308
+ path: pathname,
1309
+ peer,
1310
+ hostPresent: req.headers.has("host"),
1311
+ });
1312
+ return json(403, { error: "no-auth rejects a host that is not the loopback bind" });
1313
+ }
1300
1314
  // CORS preflight is always answered without auth — browsers send
1301
1315
  // preflights pre-authentication and a 401 here breaks the actual
1302
1316
  // request before the bearer is ever attached.
@@ -1391,8 +1405,9 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
1391
1405
  idleTimeout: 255,
1392
1406
  });
1393
1407
 
1408
+ listenPort = server.port ?? listenPort;
1394
1409
  const boundHost = server.hostname ?? bind.hostname;
1395
- const boundPort = server.port ?? bind.port;
1410
+ const boundPort = server.port ?? listenPort;
1396
1411
  return {
1397
1412
  url: `http://${boundHost}:${boundPort}`,
1398
1413
  port: boundPort,
@@ -8281,8 +8281,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
8281
8281
  this.#getCacheStmt.finalize();
8282
8282
  this.#getCacheIncludingExpiredStmt.finalize();
8283
8283
  this.#upsertCacheStmt.finalize();
8284
+ this.#claimUsageFetchLeaseStmt.finalize();
8285
+ this.#releaseUsageFetchLeaseStmt.finalize();
8284
8286
  this.#deleteCachePrefixStmt.finalize();
8285
8287
  this.#deleteExpiredCacheStmt.finalize();
8286
- this.#db.close();
8288
+ this.#db.close(true);
8287
8289
  }
8288
8290
  }
@@ -77,7 +77,7 @@ function getDb(dbPath?: string): Database {
77
77
  export function closeModelCache(dbPath?: string): boolean {
78
78
  const resolvedPath = dbPath ?? getModelDbPath();
79
79
  if (!sharedDb || sharedDbPath !== resolvedPath) return false;
80
- sharedDb.close();
80
+ sharedDb.close(true);
81
81
  sharedDb = null;
82
82
  sharedDbPath = null;
83
83
  return true;
@@ -148,9 +148,11 @@ function buildAnthropicDiscoveryHeaders(apiKey: string): Record<string, string>
148
148
  const headers: Record<string, string> = {
149
149
  "anthropic-version": "2023-06-01",
150
150
  "anthropic-dangerous-direct-browser-access": "true",
151
- "anthropic-beta": ANTHROPIC_OAUTH_BETA,
152
151
  };
153
152
  if (oauthToken) {
153
+ // Claude Code identity betas are OAuth-only; API-key requests carrying them are
154
+ // classified as Claude Code usage and excluded from API credit grants.
155
+ headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
154
156
  headers.Authorization = `Bearer ${apiKey}`;
155
157
  } else {
156
158
  headers["x-api-key"] = apiKey;
@@ -19,7 +19,9 @@ import {
19
19
  isUnexpectedSocketCloseMessage,
20
20
  logger,
21
21
  readSseEvents,
22
+ startupProjectEnvSnapshot,
22
23
  } from "@gajae-code/utils";
24
+ import { canonicalEnvKey, type ProjectEnvSnapshot } from "@gajae-code/utils/env-file";
23
25
  import {
24
26
  anthropicProviderDiagnosticFromError,
25
27
  anthropicProviderDiagnosticFromSseErrorData,
@@ -167,12 +169,8 @@ export function buildBetaHeader(baseBetas: string[], extraBetas: string[]): stri
167
169
  return result.join(",");
168
170
  }
169
171
 
170
- const claudeCodeBetaDefaults = [
171
- "claude-code-20250219",
172
- "oauth-2025-04-20",
173
- "context-management-2025-06-27",
174
- "prompt-caching-scope-2026-01-05",
175
- ];
172
+ const featureBetaDefaults = ["context-management-2025-06-27", "prompt-caching-scope-2026-01-05"];
173
+ const claudeCodeBetaDefaults = ["claude-code-20250219", "oauth-2025-04-20", ...featureBetaDefaults];
176
174
  const fineGrainedToolStreamingBeta = "fine-grained-tool-streaming-2025-05-14";
177
175
  const interleavedThinkingBeta = "interleaved-thinking-2025-05-14";
178
176
  const fastModeBeta = "fast-mode-2026-02-01";
@@ -207,6 +205,12 @@ const sharedHeaders = {
207
205
  "Content-Type": "application/json",
208
206
  "Anthropic-Version": "2023-06-01",
209
207
  "Anthropic-Dangerous-Direct-Browser-Access": "true",
208
+ };
209
+
210
+ // Claude Code client marker. Like the claude-code/oauth betas it is sent only with
211
+ // OAuth credentials: Anthropic classifies API-key requests carrying it as Claude
212
+ // Code usage, which excludes them from API credit grants.
213
+ const claudeCodeAppHeaders = {
210
214
  "X-App": "cli",
211
215
  };
212
216
 
@@ -275,7 +279,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
275
279
  const oauthToken = options.isOAuth ?? isAnthropicOAuthToken(options.apiKey);
276
280
  const extraBetas = options.extraBetas ?? [];
277
281
  const stream = options.stream ?? false;
278
- const betaHeader = buildBetaHeader(claudeCodeBetaDefaults, extraBetas);
282
+ const betaHeader = buildBetaHeader(oauthToken ? claudeCodeBetaDefaults : featureBetaDefaults, extraBetas);
279
283
  const acceptHeader = stream ? "text/event-stream" : "application/json";
280
284
  const modelHeaders = Object.fromEntries(
281
285
  Object.entries(options.modelHeaders ?? {}).filter(([key]) => !enforcedHeaderKeys.has(key.toLowerCase())),
@@ -302,6 +306,7 @@ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<s
302
306
  Accept: acceptHeader,
303
307
  Authorization: `Bearer ${options.apiKey}`,
304
308
  ...sharedHeaders,
309
+ ...claudeCodeAppHeaders,
305
310
  "Anthropic-Beta": betaHeader,
306
311
  "User-Agent": userAgent,
307
312
  };
@@ -1241,13 +1246,76 @@ function parseAnthropicCustomHeaders(rawHeaders: string | undefined): Record<str
1241
1246
  function resolveAnthropicCustomHeaders(model: Model<"anthropic-messages">): Record<string, string> | undefined {
1242
1247
  if (model.provider !== "anthropic") return undefined;
1243
1248
  if (!isFoundryEnabled()) return undefined;
1244
- return parseAnthropicCustomHeaders($env.ANTHROPIC_CUSTOM_HEADERS);
1249
+ return parseAnthropicCustomHeaders(
1250
+ trustedFoundryHeaderValue("ANTHROPIC_CUSTOM_HEADERS", startupProjectEnvSnapshot()),
1251
+ );
1245
1252
  }
1246
1253
 
1247
1254
  function looksLikeFilePath(value: string): boolean {
1248
1255
  return value.includes("/") || value.includes("\\") || /\.(pem|crt|cer|key)$/i.test(value);
1249
1256
  }
1250
1257
 
1258
+ /**
1259
+ * Bun turns `\n` and `\r` inside double-quoted dotenv values into control
1260
+ * characters before they reach `process.env`. The project snapshot keeps the
1261
+ * source text, so a literal compare would miss that rewrite.
1262
+ */
1263
+ function unescapeProjectDotenvControls(value: string): string {
1264
+ let decoded = "";
1265
+ for (let index = 0; index < value.length; index++) {
1266
+ const current = value[index];
1267
+ const next = value[index + 1];
1268
+ if (current === "\\" && next === "n") {
1269
+ decoded += "\n";
1270
+ index++;
1271
+ continue;
1272
+ }
1273
+ if (current === "\\" && next === "r") {
1274
+ decoded += "\r";
1275
+ index++;
1276
+ continue;
1277
+ }
1278
+ decoded += current ?? "";
1279
+ }
1280
+ return decoded;
1281
+ }
1282
+
1283
+ /**
1284
+ * Operator environment only. `$env` already contains the project dotenv, and a
1285
+ * value equal to that declaration — or produced from a `$` / backtick
1286
+ * declaration — must not become the Foundry trust anchor or client identity.
1287
+ */
1288
+ function trustedFoundryTlsValue(name: string, snapshot: ProjectEnvSnapshot): string | undefined {
1289
+ const raw = $env[name];
1290
+ if (!raw) return undefined;
1291
+ const key = canonicalEnvKey(name);
1292
+ const declared = snapshot.values[key];
1293
+ if (
1294
+ declared !== undefined &&
1295
+ (snapshot.dynamic.has(key) || declared === raw || unescapeProjectDotenvControls(declared) === raw)
1296
+ ) {
1297
+ return undefined;
1298
+ }
1299
+ return raw;
1300
+ }
1301
+
1302
+ /**
1303
+ * Operator environment only, for Foundry request headers.
1304
+ *
1305
+ * A `$` / backtick declaration, or a value equal to the project declaration,
1306
+ * is ignored. Literal `\n` in that declaration is not decoded: Bun decodes it
1307
+ * only inside double quotes, and decoding every declaration would also drop
1308
+ * an operator value that is a real newline.
1309
+ */
1310
+ function trustedFoundryHeaderValue(name: string, snapshot: ProjectEnvSnapshot): string | undefined {
1311
+ const raw = $env[name];
1312
+ if (!raw) return undefined;
1313
+ const key = canonicalEnvKey(name);
1314
+ const declared = snapshot.values[key];
1315
+ if (declared !== undefined && (snapshot.dynamic.has(key) || declared === raw)) return undefined;
1316
+ return raw;
1317
+ }
1318
+
1251
1319
  function resolvePemValue(value: string | undefined, name: string): string | undefined {
1252
1320
  const trimmed = value?.trim();
1253
1321
  if (!trimmed) return undefined;
@@ -1275,9 +1343,13 @@ function resolveFoundryTlsOptions(model: Model<"anthropic-messages">): FoundryTl
1275
1343
  if (model.provider !== "anthropic") return undefined;
1276
1344
  if (!isFoundryEnabled()) return undefined;
1277
1345
 
1278
- const ca = resolvePemValue($env.NODE_EXTRA_CA_CERTS, "NODE_EXTRA_CA_CERTS");
1279
- const cert = resolvePemValue($env.CLAUDE_CODE_CLIENT_CERT, "CLAUDE_CODE_CLIENT_CERT");
1280
- const key = resolvePemValue($env.CLAUDE_CODE_CLIENT_KEY, "CLAUDE_CODE_CLIENT_KEY");
1346
+ const projectEnv = startupProjectEnvSnapshot();
1347
+ const ca = resolvePemValue(trustedFoundryTlsValue("NODE_EXTRA_CA_CERTS", projectEnv), "NODE_EXTRA_CA_CERTS");
1348
+ const cert = resolvePemValue(
1349
+ trustedFoundryTlsValue("CLAUDE_CODE_CLIENT_CERT", projectEnv),
1350
+ "CLAUDE_CODE_CLIENT_CERT",
1351
+ );
1352
+ const key = resolvePemValue(trustedFoundryTlsValue("CLAUDE_CODE_CLIENT_KEY", projectEnv), "CLAUDE_CODE_CLIENT_KEY");
1281
1353
 
1282
1354
  if ((cert && !key) || (!cert && key)) {
1283
1355
  throw new Error("Both CLAUDE_CODE_CLIENT_CERT and CLAUDE_CODE_CLIENT_KEY must be set for mTLS.");
@@ -2779,6 +2779,11 @@ async function handleCodexStreamFailure(
2779
2779
  error: unknown,
2780
2780
  ): Promise<AssistantMessage> {
2781
2781
  const { output } = context;
2782
+ // Preserve failed-call identities for diagnostics, but not their unvalidated
2783
+ // arguments: a stalled call can otherwise make the terminal error oversized.
2784
+ for (const block of output.content) {
2785
+ if (block.type === "toolCall" && "partialJson" in block) block.arguments = {};
2786
+ }
2782
2787
  removeTransientBlockIndices(output);
2783
2788
  if (context.requestContext.websocketState) {
2784
2789
  resetCodexWebSocketAppendState(context.requestContext.websocketState);
@@ -2794,6 +2799,7 @@ async function handleCodexStreamFailure(
2794
2799
  codexError !== undefined && CODEX_TYPED_TRANSPORT_PROVIDER_CODES.has(codexError.code ?? "")
2795
2800
  ? codexError.code
2796
2801
  : undefined;
2802
+ if (codexError?.code === "request_timeout") output.errorCode = codexError.code;
2797
2803
  output.transportFailure = typedProviderCode
2798
2804
  ? explicitTerminalVeto
2799
2805
  ? undefined
@@ -26,7 +26,16 @@ type ErrorWithStatus = {
26
26
  status?: unknown;
27
27
  };
28
28
 
29
- const SENSITIVE_HEADERS = ["authorization", "x-api-key", "api-key", "cookie", "set-cookie", "proxy-authorization"];
29
+ const SENSITIVE_HEADERS = [
30
+ "authorization",
31
+ "x-api-key",
32
+ "api-key",
33
+ "cookie",
34
+ "set-cookie",
35
+ "proxy-authorization",
36
+ "x-goog-api-key",
37
+ "cf-aig-authorization",
38
+ ];
30
39
 
31
40
  /**
32
41
  * Connection-level failure codes, meaning the request never reached the
@@ -162,7 +171,8 @@ export async function appendRawHttpRequestDumpFor400(
162
171
  const filePath = path.join(dumpDir, fileName);
163
172
 
164
173
  try {
165
- await Bun.write(filePath, `${JSON.stringify(sanitizedDump, null, 2)}\n`);
174
+ await fs.mkdir(dumpDir, { recursive: true });
175
+ await fs.writeFile(filePath, `${JSON.stringify(sanitizedDump, null, 2)}\n`, { mode: 0o600 });
166
176
  await pruneHttpRequestDumps(dumpDir);
167
177
  return `${message}\nraw-http-request=${filePath}\n${RAW_HTTP_REQUEST_PRIVACY_NOTE}`;
168
178
  } catch (writeError) {
@@ -87,18 +87,20 @@ function parseHostPort(raw: string): number | null {
87
87
  return port;
88
88
  }
89
89
 
90
- /** Split an HTTP Host header into a hostname and an explicit port. */
91
- function parseHostHeader(value: string): { hostname: string; port: number } | null {
90
+ /** Split an HTTP Host header into a hostname and an explicit port, if present. */
91
+ function parseHostHeader(value: string): { hostname: string; port: number | null } | null {
92
92
  if (value.startsWith("[")) {
93
93
  const end = value.indexOf("]");
94
94
  if (end <= 1) return null;
95
95
  const rest = value.slice(end + 1);
96
+ if (rest.length === 0) return { hostname: value.slice(1, end), port: null };
96
97
  if (!rest.startsWith(":")) return null;
97
98
  const port = parseHostPort(rest.slice(1));
98
99
  if (port === null) return null;
99
100
  return { hostname: value.slice(1, end), port };
100
101
  }
101
102
  const colon = value.lastIndexOf(":");
103
+ if (colon === -1) return { hostname: value, port: null };
102
104
  if (colon <= 0 || value.indexOf(":") !== colon) return null;
103
105
  const port = parseHostPort(value.slice(colon + 1));
104
106
  if (port === null) return null;
@@ -108,19 +110,24 @@ function parseHostHeader(value: string): { hostname: string; port: number } | nu
108
110
  }
109
111
 
110
112
  /**
111
- * Tokenless loopback brokers accept a request only when its Host header is the
112
- * bound hostname and port. A DNS-rebound same-origin GET sends the attacker's
113
- * name and no Origin header.
113
+ * Tokenless loopback servers accept a request only when its Host header is the
114
+ * bound hostname and effective port. Default-port forms that stay allowed:
115
+ * a Host with no port, or an explicit :80, on an HTTP listener on port 80;
116
+ * an explicit :443 on a listener on port 443. Any other omitted port is
117
+ * rejected. A DNS-rebound same-origin GET sends the attacker's name and no
118
+ * Origin header.
114
119
  */
115
120
  export function hostHeaderMatchesBind(hostHeader: string | null, bind: ParsedBind): boolean {
116
121
  if (hostHeader === null) return false;
117
122
  const trimmed = hostHeader.trim();
118
123
  if (trimmed.length === 0 || /[\s@]/.test(trimmed)) return false;
119
124
  const parsed = parseHostHeader(trimmed);
120
- if (!parsed) return false;
125
+ if (!parsed || parsed.hostname.length === 0) return false;
126
+ const port = parsed.port ?? (bind.port === 80 ? 80 : null);
127
+ if (port === null) return false;
121
128
  const bindHost = bind.hostname
122
129
  .trim()
123
130
  .toLowerCase()
124
131
  .replace(/^\[|\]$/g, "");
125
- return parsed.hostname.toLowerCase() === bindHost && parsed.port === bind.port;
132
+ return parsed.hostname.toLowerCase() === bindHost && port === bind.port;
126
133
  }