llm-cli-gateway 2.7.0 → 2.9.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 (54) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +28 -1
  3. package/dist/acp/client.d.ts +78 -0
  4. package/dist/acp/client.js +201 -0
  5. package/dist/acp/errors.d.ts +63 -0
  6. package/dist/acp/errors.js +139 -0
  7. package/dist/acp/json-rpc-stdio.d.ts +71 -0
  8. package/dist/acp/json-rpc-stdio.js +375 -0
  9. package/dist/acp/process-manager.d.ts +66 -0
  10. package/dist/acp/process-manager.js +364 -0
  11. package/dist/acp/provider-registry.d.ts +24 -0
  12. package/dist/acp/provider-registry.js +82 -0
  13. package/dist/acp/types.d.ts +557 -0
  14. package/dist/acp/types.js +335 -0
  15. package/dist/approval-manager.d.ts +1 -0
  16. package/dist/approval-manager.js +14 -1
  17. package/dist/async-job-manager.d.ts +3 -0
  18. package/dist/async-job-manager.js +56 -16
  19. package/dist/auth.d.ts +4 -0
  20. package/dist/auth.js +16 -0
  21. package/dist/cache-stats.d.ts +1 -0
  22. package/dist/cache-stats.js +19 -11
  23. package/dist/cli-updater.js +5 -2
  24. package/dist/codex-json-parser.d.ts +3 -0
  25. package/dist/codex-json-parser.js +17 -0
  26. package/dist/config.d.ts +30 -0
  27. package/dist/config.js +140 -0
  28. package/dist/flight-recorder.d.ts +7 -1
  29. package/dist/flight-recorder.js +33 -6
  30. package/dist/http-transport.js +21 -18
  31. package/dist/index.js +104 -34
  32. package/dist/job-store.d.ts +4 -0
  33. package/dist/job-store.js +16 -4
  34. package/dist/oauth.d.ts +2 -0
  35. package/dist/oauth.js +90 -8
  36. package/dist/pricing.d.ts +1 -1
  37. package/dist/pricing.js +67 -2
  38. package/dist/provider-tool-capabilities.d.ts +38 -0
  39. package/dist/provider-tool-capabilities.js +142 -0
  40. package/dist/request-context.d.ts +4 -0
  41. package/dist/request-context.js +16 -0
  42. package/dist/request-helpers.d.ts +4 -4
  43. package/dist/request-limits.d.ts +8 -0
  44. package/dist/request-limits.js +49 -0
  45. package/dist/secret-redaction.d.ts +3 -0
  46. package/dist/secret-redaction.js +53 -0
  47. package/dist/session-manager-pg.js +8 -5
  48. package/dist/session-manager.d.ts +1 -0
  49. package/dist/session-manager.js +2 -0
  50. package/dist/upstream-contracts.d.ts +27 -0
  51. package/dist/upstream-contracts.js +131 -0
  52. package/migrations/004_session_owner_principal.sql +10 -0
  53. package/npm-shrinkwrap.json +2 -2
  54. package/package.json +1 -1
@@ -0,0 +1,335 @@
1
+ import { z } from "zod/v3";
2
+ import { AcpProtocolError } from "./errors.js";
3
+ export const ProtocolVersionSchema = z.number().int().nonnegative();
4
+ export const SessionIdSchema = z.string().min(1);
5
+ const MetaSchema = z.record(z.unknown()).optional();
6
+ const KNOWN_CONTENT_BLOCK_TYPES = new Set(["text", "image", "audio", "resource_link", "resource"]);
7
+ export const ContentBlockSchema = z.lazy(() => z.union([
8
+ z.object({ type: z.literal("text"), text: z.string(), _meta: MetaSchema }).passthrough(),
9
+ z
10
+ .object({
11
+ type: z.literal("image"),
12
+ data: z.string(),
13
+ mimeType: z.string(),
14
+ _meta: MetaSchema,
15
+ })
16
+ .passthrough(),
17
+ z
18
+ .object({
19
+ type: z.literal("audio"),
20
+ data: z.string(),
21
+ mimeType: z.string(),
22
+ _meta: MetaSchema,
23
+ })
24
+ .passthrough(),
25
+ z
26
+ .object({
27
+ type: z.literal("resource_link"),
28
+ uri: z.string(),
29
+ _meta: MetaSchema,
30
+ })
31
+ .passthrough(),
32
+ z.object({ type: z.literal("resource"), _meta: MetaSchema }).passthrough(),
33
+ z
34
+ .object({
35
+ type: z.string().refine(t => !KNOWN_CONTENT_BLOCK_TYPES.has(t), {
36
+ message: "known content block type missing required fields",
37
+ }),
38
+ })
39
+ .passthrough(),
40
+ ]));
41
+ export const ClientFsCapabilitiesSchema = z
42
+ .object({
43
+ readTextFile: z.boolean().optional(),
44
+ writeTextFile: z.boolean().optional(),
45
+ })
46
+ .passthrough();
47
+ export const ClientCapabilitiesSchema = z
48
+ .object({
49
+ fs: ClientFsCapabilitiesSchema.optional(),
50
+ terminal: z.boolean().optional(),
51
+ })
52
+ .passthrough();
53
+ export const InitializeRequestSchema = z
54
+ .object({
55
+ protocolVersion: ProtocolVersionSchema,
56
+ clientCapabilities: ClientCapabilitiesSchema.optional(),
57
+ _meta: MetaSchema,
58
+ })
59
+ .passthrough();
60
+ export const InitializeResponseSchema = z
61
+ .object({
62
+ protocolVersion: ProtocolVersionSchema,
63
+ agentCapabilities: z.record(z.unknown()).optional(),
64
+ agentInfo: z
65
+ .object({
66
+ name: z.string().optional(),
67
+ version: z.string().optional(),
68
+ })
69
+ .passthrough()
70
+ .optional(),
71
+ authMethods: z.array(z.record(z.unknown())).optional(),
72
+ _meta: MetaSchema,
73
+ })
74
+ .passthrough();
75
+ export const McpServerSchema = z.record(z.unknown());
76
+ export const SessionNewRequestSchema = z
77
+ .object({
78
+ cwd: z.string().min(1),
79
+ mcpServers: z.array(McpServerSchema).default([]),
80
+ _meta: MetaSchema,
81
+ })
82
+ .passthrough();
83
+ export const SessionNewResponseSchema = z
84
+ .object({
85
+ sessionId: SessionIdSchema,
86
+ modes: z.record(z.unknown()).optional(),
87
+ _meta: MetaSchema,
88
+ })
89
+ .passthrough();
90
+ export const SessionLoadRequestSchema = z
91
+ .object({
92
+ sessionId: SessionIdSchema,
93
+ cwd: z.string().min(1),
94
+ mcpServers: z.array(McpServerSchema).default([]),
95
+ _meta: MetaSchema,
96
+ })
97
+ .passthrough();
98
+ export const SessionLoadResponseSchema = z
99
+ .object({
100
+ modes: z.record(z.unknown()).optional(),
101
+ _meta: MetaSchema,
102
+ })
103
+ .passthrough();
104
+ export const SessionPromptRequestSchema = z
105
+ .object({
106
+ sessionId: SessionIdSchema,
107
+ prompt: z.array(ContentBlockSchema).min(1),
108
+ _meta: MetaSchema,
109
+ })
110
+ .passthrough();
111
+ export const STOP_REASONS = [
112
+ "end_turn",
113
+ "max_tokens",
114
+ "max_turn_requests",
115
+ "refusal",
116
+ "cancelled",
117
+ ];
118
+ export const SessionPromptResponseSchema = z
119
+ .object({
120
+ stopReason: z.string().min(1),
121
+ _meta: MetaSchema,
122
+ })
123
+ .passthrough();
124
+ export const SessionCancelNotificationSchema = z
125
+ .object({
126
+ sessionId: SessionIdSchema,
127
+ _meta: MetaSchema,
128
+ })
129
+ .passthrough();
130
+ const MessageChunkUpdate = (variant) => z
131
+ .object({
132
+ sessionUpdate: z.literal(variant),
133
+ content: ContentBlockSchema,
134
+ messageId: z.string().nullish(),
135
+ _meta: MetaSchema,
136
+ })
137
+ .passthrough();
138
+ const ToolCallUpdate = z
139
+ .object({
140
+ sessionUpdate: z.literal("tool_call"),
141
+ toolCallId: z.string().min(1),
142
+ title: z.string(),
143
+ status: z.string().optional(),
144
+ kind: z.string().optional(),
145
+ _meta: MetaSchema,
146
+ })
147
+ .passthrough();
148
+ const ToolCallUpdateUpdate = z
149
+ .object({
150
+ sessionUpdate: z.literal("tool_call_update"),
151
+ toolCallId: z.string().min(1),
152
+ status: z.string().nullish(),
153
+ title: z.string().nullish(),
154
+ _meta: MetaSchema,
155
+ })
156
+ .passthrough();
157
+ const PlanUpdate = z
158
+ .object({
159
+ sessionUpdate: z.literal("plan"),
160
+ entries: z.array(z.record(z.unknown())),
161
+ _meta: MetaSchema,
162
+ })
163
+ .passthrough();
164
+ const AvailableCommandsUpdate = z
165
+ .object({
166
+ sessionUpdate: z.literal("available_commands_update"),
167
+ availableCommands: z.array(z.record(z.unknown())),
168
+ _meta: MetaSchema,
169
+ })
170
+ .passthrough();
171
+ const CurrentModeUpdate = z
172
+ .object({
173
+ sessionUpdate: z.literal("current_mode_update"),
174
+ currentModeId: z.string().min(1),
175
+ _meta: MetaSchema,
176
+ })
177
+ .passthrough();
178
+ const UsageUpdate = z
179
+ .object({
180
+ sessionUpdate: z.literal("usage_update"),
181
+ size: z.number().int().nonnegative(),
182
+ used: z.number().int().nonnegative(),
183
+ cost: z.record(z.unknown()).nullish(),
184
+ _meta: MetaSchema,
185
+ })
186
+ .passthrough();
187
+ const KNOWN_UPDATE_SCHEMAS = {
188
+ user_message_chunk: MessageChunkUpdate("user_message_chunk"),
189
+ agent_message_chunk: MessageChunkUpdate("agent_message_chunk"),
190
+ agent_thought_chunk: MessageChunkUpdate("agent_thought_chunk"),
191
+ tool_call: ToolCallUpdate,
192
+ tool_call_update: ToolCallUpdateUpdate,
193
+ plan: PlanUpdate,
194
+ available_commands_update: AvailableCommandsUpdate,
195
+ current_mode_update: CurrentModeUpdate,
196
+ usage_update: UsageUpdate,
197
+ };
198
+ export const SessionUpdateSchema = z
199
+ .object({ sessionUpdate: z.string().min(1) })
200
+ .passthrough()
201
+ .superRefine((value, ctx) => {
202
+ const known = KNOWN_UPDATE_SCHEMAS[value.sessionUpdate];
203
+ if (!known) {
204
+ return;
205
+ }
206
+ const result = known.safeParse(value);
207
+ if (!result.success) {
208
+ for (const issue of result.error.issues) {
209
+ ctx.addIssue(issue);
210
+ }
211
+ }
212
+ });
213
+ export const SessionUpdateNotificationSchema = z
214
+ .object({
215
+ sessionId: SessionIdSchema,
216
+ update: SessionUpdateSchema,
217
+ _meta: MetaSchema,
218
+ })
219
+ .passthrough();
220
+ export const KNOWN_SESSION_UPDATE_VARIANTS = [
221
+ "user_message_chunk",
222
+ "agent_message_chunk",
223
+ "agent_thought_chunk",
224
+ "tool_call",
225
+ "tool_call_update",
226
+ "plan",
227
+ "available_commands_update",
228
+ "current_mode_update",
229
+ "config_option_update",
230
+ "session_info_update",
231
+ "usage_update",
232
+ ];
233
+ export function isUnknownSessionUpdate(update) {
234
+ return !KNOWN_SESSION_UPDATE_VARIANTS.includes(update.sessionUpdate);
235
+ }
236
+ export const PermissionOptionSchema = z
237
+ .object({
238
+ optionId: z.string().min(1),
239
+ name: z.string(),
240
+ kind: z.string().optional(),
241
+ _meta: MetaSchema,
242
+ })
243
+ .passthrough();
244
+ export const RequestPermissionRequestSchema = z
245
+ .object({
246
+ sessionId: SessionIdSchema,
247
+ options: z.array(PermissionOptionSchema).min(1),
248
+ toolCall: z.record(z.unknown()),
249
+ _meta: MetaSchema,
250
+ })
251
+ .passthrough();
252
+ export const RequestPermissionOutcomeSchema = z.union([
253
+ z.object({ outcome: z.literal("cancelled") }).passthrough(),
254
+ z.object({ outcome: z.literal("selected"), optionId: z.string().min(1) }).passthrough(),
255
+ ]);
256
+ export const RequestPermissionResponseSchema = z
257
+ .object({
258
+ outcome: RequestPermissionOutcomeSchema,
259
+ _meta: MetaSchema,
260
+ })
261
+ .passthrough();
262
+ export const ReadTextFileRequestSchema = z
263
+ .object({
264
+ sessionId: SessionIdSchema,
265
+ path: z.string().min(1),
266
+ line: z.number().int().nonnegative().nullish(),
267
+ limit: z.number().int().nonnegative().nullish(),
268
+ _meta: MetaSchema,
269
+ })
270
+ .passthrough();
271
+ export const ReadTextFileResponseSchema = z
272
+ .object({
273
+ content: z.string(),
274
+ _meta: MetaSchema,
275
+ })
276
+ .passthrough();
277
+ export const WriteTextFileRequestSchema = z
278
+ .object({
279
+ sessionId: SessionIdSchema,
280
+ path: z.string().min(1),
281
+ content: z.string(),
282
+ _meta: MetaSchema,
283
+ })
284
+ .passthrough();
285
+ export const WriteTextFileResponseSchema = z.object({ _meta: MetaSchema }).passthrough();
286
+ export function parseAcp(schema, value, context) {
287
+ const result = schema.safeParse(value);
288
+ if (result.success) {
289
+ return result.data;
290
+ }
291
+ const issuePaths = result.error.issues.map(issue => ({
292
+ path: issue.path.join("."),
293
+ code: issue.code,
294
+ }));
295
+ throw new AcpProtocolError(`Malformed ACP ${context.method} payload`, {
296
+ provider: context.provider,
297
+ debug: { method: context.method, issues: issuePaths },
298
+ });
299
+ }
300
+ export function parseInitializeResponse(value, provider) {
301
+ return parseAcp(InitializeResponseSchema, value, { method: "initialize", provider });
302
+ }
303
+ export function parseSessionNewResponse(value, provider) {
304
+ return parseAcp(SessionNewResponseSchema, value, { method: "session/new", provider });
305
+ }
306
+ export function parseSessionLoadResponse(value, provider) {
307
+ return parseAcp(SessionLoadResponseSchema, value, { method: "session/load", provider });
308
+ }
309
+ export function parseSessionPromptResponse(value, provider) {
310
+ return parseAcp(SessionPromptResponseSchema, value, { method: "session/prompt", provider });
311
+ }
312
+ export function parseSessionUpdateNotification(value, provider) {
313
+ return parseAcp(SessionUpdateNotificationSchema, value, {
314
+ method: "session/update",
315
+ provider,
316
+ });
317
+ }
318
+ export function parseRequestPermissionRequest(value, provider) {
319
+ return parseAcp(RequestPermissionRequestSchema, value, {
320
+ method: "session/request_permission",
321
+ provider,
322
+ });
323
+ }
324
+ export function parseReadTextFileRequest(value, provider) {
325
+ return parseAcp(ReadTextFileRequestSchema, value, {
326
+ method: "fs/read_text_file",
327
+ provider,
328
+ });
329
+ }
330
+ export function parseWriteTextFileRequest(value, provider) {
331
+ return parseAcp(WriteTextFileRequestSchema, value, {
332
+ method: "fs/write_text_file",
333
+ provider,
334
+ });
335
+ }
@@ -34,6 +34,7 @@ export interface ApprovalRecord {
34
34
  metadata?: Record<string, unknown>;
35
35
  reviewIntegrity?: ReviewIntegrityResult;
36
36
  }
37
+ export declare function bypassAllowedByOperator(env?: NodeJS.ProcessEnv): boolean;
37
38
  export declare class ApprovalManager {
38
39
  private logger;
39
40
  private readonly logPath;
@@ -14,6 +14,10 @@ function parsePolicy(policy) {
14
14
  }
15
15
  return "balanced";
16
16
  }
17
+ export function bypassAllowedByOperator(env = process.env) {
18
+ const raw = (env.LLM_GATEWAY_APPROVAL_ALLOW_BYPASS || "").trim().toLowerCase();
19
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
20
+ }
17
21
  function promptPreview(prompt) {
18
22
  if (process.env.APPROVAL_LOG_PROMPTS === "1") {
19
23
  return prompt.replace(/\s+/g, " ").trim().slice(0, 280);
@@ -106,8 +110,17 @@ export class ApprovalManager {
106
110
  reasons.push(`Review integrity: ${violation.detail}`);
107
111
  }
108
112
  }
113
+ const bypassDeniedByDefault = request.bypassRequested && !bypassAllowedByOperator();
114
+ if (bypassDeniedByDefault) {
115
+ reasons.push("Full permission/sandbox bypass denied by default under MCP-managed approval " +
116
+ "(set LLM_GATEWAY_APPROVAL_ALLOW_BYPASS=1 to permit)");
117
+ }
109
118
  const threshold = policy === "strict" ? 2 : policy === "balanced" ? 5 : 7;
110
- const status = score <= threshold ? "approved" : "denied";
119
+ const status = bypassDeniedByDefault
120
+ ? "denied"
121
+ : score <= threshold
122
+ ? "approved"
123
+ : "denied";
111
124
  const record = {
112
125
  id: randomUUID(),
113
126
  ts: new Date().toISOString(),
@@ -67,6 +67,7 @@ export declare class AsyncJobManager {
67
67
  private store;
68
68
  private flightRecorder;
69
69
  constructor(logger?: Logger, onJobComplete?: ((cli: LlmCli, durationMs: number, success: boolean) => void) | undefined, store?: JobStore | null, flightRecorder?: FlightRecorderLike);
70
+ private buildOrphanFlightResult;
70
71
  checkStalledJobs(now?: number): void;
71
72
  hasStore(): boolean;
72
73
  private emitMetrics;
@@ -80,6 +81,7 @@ export declare class AsyncJobManager {
80
81
  private maybeFlushOutput;
81
82
  private persistComplete;
82
83
  private hydrateFromStore;
84
+ getJobOwner(jobId: string): string | null | undefined;
83
85
  startJob(cli: LlmCli, args: string[], correlationId: string, cwd?: string, idleTimeoutMs?: number, outputFormat?: string, forceRefresh?: boolean, env?: Record<string, string>, onComplete?: () => void, flightRecorderEntry?: AsyncJobFlightRecorderEntry, extractUsage?: AsyncJobUsageExtractor, writeFlightStart?: boolean, stdin?: string): AsyncJobSnapshot;
84
86
  startJobWithDedup(cli: LlmCli, args: string[], correlationId: string, opts?: StartJobOptions): StartJobOutcome;
85
87
  getJobSnapshot(jobId: string): AsyncJobSnapshot | null;
@@ -103,6 +105,7 @@ export declare class AsyncJobManager {
103
105
  jobs: JobHealth[];
104
106
  };
105
107
  getJobOutputFormat(jobId: string): string | undefined;
108
+ getJobCli(jobId: string): LlmCli | undefined;
106
109
  private snapshot;
107
110
  private appendOutput;
108
111
  }
@@ -3,7 +3,9 @@ import { envWithExtendedPath, getExtendedPath, killProcessGroup, providerCommand
3
3
  import { noopLogger, logWarn } from "./logger.js";
4
4
  import { ProcessMonitor } from "./process-monitor.js";
5
5
  import { computeRequestKey } from "./job-store.js";
6
- import { NoopFlightRecorder } from "./flight-recorder.js";
6
+ import { NoopFlightRecorder, } from "./flight-recorder.js";
7
+ import { codexFrResponse } from "./codex-json-parser.js";
8
+ import { getRequestContext, resolveOwnerPrincipal } from "./request-context.js";
7
9
  const MAX_OUTPUT_SIZE = 50 * 1024 * 1024;
8
10
  const JOB_TTL_MS = 60 * 60 * 1000;
9
11
  const EVICTION_INTERVAL_MS = 5 * 60 * 1000;
@@ -75,17 +77,7 @@ export class AsyncJobManager {
75
77
  }
76
78
  for (const orphan of orphaned) {
77
79
  try {
78
- const durationMs = Math.max(0, Date.now() - new Date(orphan.startedAt).getTime());
79
- this.flightRecorder.logComplete(orphan.correlationId, {
80
- response: orphan.stderr || orphan.stdout,
81
- durationMs,
82
- retryCount: 0,
83
- circuitBreakerState: "closed",
84
- optimizationApplied: false,
85
- exitCode: orphan.exitCode ?? 1,
86
- errorMessage: "orphaned after gateway restart",
87
- status: "failed",
88
- });
80
+ this.flightRecorder.logComplete(orphan.correlationId, this.buildOrphanFlightResult(orphan));
89
81
  }
90
82
  catch (err) {
91
83
  this.logger.error(`Async-path FR logComplete for orphaned job ${orphan.id} failed`, err);
@@ -105,6 +97,33 @@ export class AsyncJobManager {
105
97
  this.stallTimer.unref();
106
98
  }
107
99
  }
100
+ buildOrphanFlightResult(orphan) {
101
+ const durationMs = Math.max(0, Date.now() - new Date(orphan.startedAt).getTime());
102
+ const hasCapturedStdout = orphan.stdout.length > 0;
103
+ const hasKnownSuccessfulExit = orphan.exitCode === 0;
104
+ const hasCapturedResponseWithoutFailure = orphan.exitCode === null && hasCapturedStdout;
105
+ if (hasKnownSuccessfulExit || hasCapturedResponseWithoutFailure) {
106
+ return {
107
+ response: orphan.stdout,
108
+ durationMs,
109
+ retryCount: 0,
110
+ circuitBreakerState: "closed",
111
+ optimizationApplied: false,
112
+ exitCode: 0,
113
+ status: "completed",
114
+ };
115
+ }
116
+ return {
117
+ response: orphan.stderr || orphan.stdout,
118
+ durationMs,
119
+ retryCount: 0,
120
+ circuitBreakerState: "closed",
121
+ optimizationApplied: false,
122
+ exitCode: orphan.exitCode ?? 1,
123
+ errorMessage: "orphaned after gateway restart",
124
+ status: "failed",
125
+ };
126
+ }
108
127
  checkStalledJobs(now = Date.now()) {
109
128
  for (const job of this.jobs.values()) {
110
129
  if (job.status !== "running")
@@ -218,10 +237,11 @@ export class AsyncJobManager {
218
237
  }
219
238
  }
220
239
  }
221
- buildRequestKey(cli, args, env, stdin, cwd) {
240
+ buildRequestKey(cli, args, env, stdin, cwd, outputFormat) {
222
241
  const extraEnv = canonicaliseEnvForKey(env);
223
242
  const withStdin = stdin === undefined ? extraEnv : `${extraEnv}|stdin:${stdin}`;
224
- const extra = cwd === undefined ? withStdin : `${withStdin}|cwd:${cwd}`;
243
+ const withCwd = cwd === undefined ? withStdin : `${withStdin}|cwd:${cwd}`;
244
+ const extra = cli === "codex" ? `${withCwd}|fmt:${outputFormat ?? "text"}` : withCwd;
225
245
  return computeRequestKey(cli, args, extra);
226
246
  }
227
247
  fireOnComplete(job) {
@@ -247,7 +267,14 @@ export class AsyncJobManager {
247
267
  const durationMs = Math.max(0, Date.now() - new Date(job.startedAt).getTime());
248
268
  const usage = finalStatus === "completed" && job.extractUsage ? this.safeExtractUsage(job) : {};
249
269
  const isFailure = finalStatus === "failed";
250
- const response = isFailure ? job.stderr || job.stdout : job.stdout;
270
+ let response;
271
+ if (job.cli === "codex") {
272
+ const codexText = codexFrResponse(job.outputFormat, job.stdout);
273
+ response = isFailure ? job.stderr || codexText : codexText;
274
+ }
275
+ else {
276
+ response = isFailure ? job.stderr || job.stdout : job.stdout;
277
+ }
251
278
  const exitCode = job.exitCode ?? (finalStatus === "completed" ? 0 : 1);
252
279
  const errorMessage = isFailure
253
280
  ? (overrideErrorMessage ?? job.error ?? job.stderr ?? `Exit code ${exitCode}`)
@@ -380,12 +407,19 @@ export class AsyncJobManager {
380
407
  exited: row.status !== "running",
381
408
  metricsRecorded: true,
382
409
  outputFormat: row.outputFormat ?? undefined,
410
+ ownerPrincipal: row.ownerPrincipal,
383
411
  outputDirty: false,
384
412
  lastOutputFlushAt: Date.now(),
385
413
  };
386
414
  this.jobs.set(jobId, reconstituted);
387
415
  return reconstituted;
388
416
  }
417
+ getJobOwner(jobId) {
418
+ let job = this.jobs.get(jobId);
419
+ if (!job)
420
+ job = this.hydrateFromStore(jobId) ?? undefined;
421
+ return job?.ownerPrincipal;
422
+ }
389
423
  startJob(cli, args, correlationId, cwd, idleTimeoutMs, outputFormat, forceRefresh, env, onComplete, flightRecorderEntry, extractUsage, writeFlightStart, stdin) {
390
424
  return this.startJobWithDedup(cli, args, correlationId, {
391
425
  cwd,
@@ -402,7 +436,7 @@ export class AsyncJobManager {
402
436
  }
403
437
  startJobWithDedup(cli, args, correlationId, opts = {}) {
404
438
  const { cwd, idleTimeoutMs, outputFormat, forceRefresh, env: extraEnv, stdin, onComplete, flightRecorderEntry, extractUsage, writeFlightStart, } = opts;
405
- const requestKey = this.buildRequestKey(cli, args, extraEnv, stdin, cwd);
439
+ const requestKey = this.buildRequestKey(cli, args, extraEnv, stdin, cwd, outputFormat);
406
440
  if (!forceRefresh && this.store) {
407
441
  try {
408
442
  const existing = this.store.findByRequestKey(requestKey);
@@ -463,9 +497,11 @@ export class AsyncJobManager {
463
497
  if (child.pid)
464
498
  unregisterProcessGroup(child.pid);
465
499
  };
500
+ const ownerPrincipal = resolveOwnerPrincipal(getRequestContext());
466
501
  const job = {
467
502
  id,
468
503
  cli,
504
+ ownerPrincipal,
469
505
  args: [...args],
470
506
  requestKey,
471
507
  correlationId,
@@ -502,6 +538,7 @@ export class AsyncJobManager {
502
538
  outputFormat,
503
539
  startedAt,
504
540
  pid: child.pid ?? null,
541
+ ownerPrincipal,
505
542
  }));
506
543
  if (writeFlightStart && flightRecorderEntry) {
507
544
  try {
@@ -713,6 +750,9 @@ export class AsyncJobManager {
713
750
  getJobOutputFormat(jobId) {
714
751
  return this.jobs.get(jobId)?.outputFormat;
715
752
  }
753
+ getJobCli(jobId) {
754
+ return this.jobs.get(jobId)?.cli;
755
+ }
716
756
  snapshot(job) {
717
757
  return {
718
758
  id: job.id,
package/dist/auth.d.ts CHANGED
@@ -32,6 +32,8 @@ export interface RemoteOAuthConfig {
32
32
  registrationPolicy: OAuthRegistrationPolicy;
33
33
  allowPublicClients: boolean;
34
34
  tokenTtlSeconds: number;
35
+ requireConsent: boolean;
36
+ consentSecretHash: string | null;
35
37
  clients: RemoteOAuthClientConfig[];
36
38
  sharedSecret: RemoteOAuthSharedSecretConfig | null;
37
39
  sources: {
@@ -53,6 +55,8 @@ export declare function issueOAuthAccessToken(args: {
53
55
  scope: string;
54
56
  };
55
57
  export declare function authorizeBearerRequest(req: IncomingMessage, token?: string | null): AuthResult;
58
+ export declare function trustedPrincipalHeaderName(env?: NodeJS.ProcessEnv): string | null;
59
+ export declare function resolveTrustedPrincipal(req: IncomingMessage, auth: AuthResult, env?: NodeJS.ProcessEnv): string | undefined;
56
60
  export declare function writeAuthFailure(res: ServerResponse, result: AuthResult, options?: {
57
61
  resourceMetadataUrl?: string;
58
62
  }): void;
package/dist/auth.js CHANGED
@@ -79,6 +79,22 @@ export function authorizeBearerRequest(req, token = getRequiredBearerToken()) {
79
79
  }
80
80
  return { ok: false, status: 401, message: "Unauthorized" };
81
81
  }
82
+ const TRUSTED_PRINCIPAL_PATTERN = /^[A-Za-z0-9._@:+=/-]{1,256}$/;
83
+ export function trustedPrincipalHeaderName(env = process.env) {
84
+ const raw = (env.LLM_GATEWAY_TRUSTED_PRINCIPAL_HEADER || "").trim().toLowerCase();
85
+ return raw || null;
86
+ }
87
+ export function resolveTrustedPrincipal(req, auth, env = process.env) {
88
+ const headerName = trustedPrincipalHeaderName(env);
89
+ if (!headerName || auth.kind !== "gateway_bearer")
90
+ return undefined;
91
+ const raw = req.headers[headerName];
92
+ const value = Array.isArray(raw) ? raw[0] : raw;
93
+ if (!value)
94
+ return undefined;
95
+ const trimmed = value.trim();
96
+ return TRUSTED_PRINCIPAL_PATTERN.test(trimmed) ? trimmed : undefined;
97
+ }
82
98
  export function writeAuthFailure(res, result, options = {}) {
83
99
  const status = result.status ?? 401;
84
100
  let wwwAuthenticate = 'Bearer realm="llm-cli-gateway"';
@@ -90,6 +90,7 @@ export interface PersistedRequestRecord {
90
90
  response: string | null;
91
91
  prompt?: string;
92
92
  thinkingBlocks: string[] | null;
93
+ ownerPrincipal: string | null;
93
94
  }
94
95
  export interface ReadPersistedRequestOptions {
95
96
  maxChars?: number;
@@ -5,6 +5,11 @@ function safeNum(n) {
5
5
  function isCacheStatsCli(s) {
6
6
  return s === "claude" || s === "codex" || s === "gemini" || s === "grok" || s === "mistral";
7
7
  }
8
+ function normalizeCacheStatsCli(s) {
9
+ if (s === "grok-api")
10
+ return "grok";
11
+ return isCacheStatsCli(s) ? s : null;
12
+ }
8
13
  export function computeSessionCacheStats(db, sessionId) {
9
14
  const rows = db.queryRequests(`SELECT cli, model,
10
15
  COALESCE(cache_read_tokens, 0) AS cache_read_tokens,
@@ -34,10 +39,11 @@ export function computeSessionCacheStats(db, sessionId) {
34
39
  prefixSet.add(row.stable_prefix_hash);
35
40
  if (!lastAt || row.datetime_utc > lastAt)
36
41
  lastAt = row.datetime_utc;
37
- if (cli === null && isCacheStatsCli(row.cli))
38
- cli = row.cli;
39
- if (isCacheStatsCli(row.cli)) {
40
- estimatedSavingsUsd += estimateCacheSavingsUsd(row.cli, row.model, reads);
42
+ const rowCli = normalizeCacheStatsCli(row.cli);
43
+ if (cli === null && rowCli !== null)
44
+ cli = rowCli;
45
+ if (rowCli !== null) {
46
+ estimatedSavingsUsd += estimateCacheSavingsUsd(rowCli, row.model, reads);
41
47
  }
42
48
  }
43
49
  const requestCount = rows.length;
@@ -102,15 +108,16 @@ export function computePrefixCacheStats(db, stablePrefixHash) {
102
108
  if (!firstAt)
103
109
  firstAt = row.datetime_utc;
104
110
  lastAt = row.datetime_utc;
105
- if (isCacheStatsCli(row.cli)) {
106
- estimatedSavingsUsd += estimateCacheSavingsUsd(row.cli, row.model, reads);
107
- const key = `${row.cli}::${row.model}`;
111
+ const rowCli = normalizeCacheStatsCli(row.cli);
112
+ if (rowCli !== null) {
113
+ estimatedSavingsUsd += estimateCacheSavingsUsd(rowCli, row.model, reads);
114
+ const key = `${rowCli}::${row.model}`;
108
115
  const entry = cliMap.get(key);
109
116
  if (entry) {
110
117
  entry.count += 1;
111
118
  }
112
119
  else {
113
- cliMap.set(key, { cli: row.cli, model: row.model, count: 1 });
120
+ cliMap.set(key, { cli: rowCli, model: row.model, count: 1 });
114
121
  }
115
122
  }
116
123
  }
@@ -180,9 +187,9 @@ export function computeGlobalCacheStats(db, opts = {}) {
180
187
  arr.push({ datetime_utc: row.datetime_utc, cache_creation_tokens: creation });
181
188
  perPrefix.set(row.stable_prefix_hash, arr);
182
189
  }
183
- if (!isCacheStatsCli(row.cli))
190
+ const cli = normalizeCacheStatsCli(row.cli);
191
+ if (cli === null)
184
192
  continue;
185
- const cli = row.cli;
186
193
  const savings = estimateCacheSavingsUsd(cli, row.model, reads);
187
194
  totalSavings += savings;
188
195
  const agg = perCliMap.get(cli) ?? {
@@ -256,7 +263,7 @@ export function readPersistedRequest(db, correlationId, opts = {}) {
256
263
  const maxChars = opts.maxChars ?? PERSISTED_REQUEST_DEFAULT_MAX_CHARS;
257
264
  const rows = db.queryRequests(`SELECT r.id, r.cli, r.model, r.prompt, r.response, r.session_id,
258
265
  r.datetime_utc, r.duration_ms, r.input_tokens, r.output_tokens,
259
- r.cache_read_tokens, r.cache_creation_tokens,
266
+ r.cache_read_tokens, r.cache_creation_tokens, r.owner_principal,
260
267
  m.retry_count, m.circuit_breaker_state, m.cost_usd,
261
268
  m.exit_code, m.error_message, m.async_job_id, m.status,
262
269
  m.thinking_blocks
@@ -294,6 +301,7 @@ export function readPersistedRequest(db, correlationId, opts = {}) {
294
301
  responseTruncated,
295
302
  response,
296
303
  thinkingBlocks: parseThinkingBlocks(row.thinking_blocks),
304
+ ownerPrincipal: row.owner_principal,
297
305
  };
298
306
  if (opts.includePrompt) {
299
307
  record.prompt = row.prompt ?? "";