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.
- package/CHANGELOG.md +82 -0
- package/README.md +28 -1
- package/dist/acp/client.d.ts +78 -0
- package/dist/acp/client.js +201 -0
- package/dist/acp/errors.d.ts +63 -0
- package/dist/acp/errors.js +139 -0
- package/dist/acp/json-rpc-stdio.d.ts +71 -0
- package/dist/acp/json-rpc-stdio.js +375 -0
- package/dist/acp/process-manager.d.ts +66 -0
- package/dist/acp/process-manager.js +364 -0
- package/dist/acp/provider-registry.d.ts +24 -0
- package/dist/acp/provider-registry.js +82 -0
- package/dist/acp/types.d.ts +557 -0
- package/dist/acp/types.js +335 -0
- package/dist/approval-manager.d.ts +1 -0
- package/dist/approval-manager.js +14 -1
- package/dist/async-job-manager.d.ts +3 -0
- package/dist/async-job-manager.js +56 -16
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +16 -0
- package/dist/cache-stats.d.ts +1 -0
- package/dist/cache-stats.js +19 -11
- package/dist/cli-updater.js +5 -2
- package/dist/codex-json-parser.d.ts +3 -0
- package/dist/codex-json-parser.js +17 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +140 -0
- package/dist/flight-recorder.d.ts +7 -1
- package/dist/flight-recorder.js +33 -6
- package/dist/http-transport.js +21 -18
- package/dist/index.js +104 -34
- package/dist/job-store.d.ts +4 -0
- package/dist/job-store.js +16 -4
- package/dist/oauth.d.ts +2 -0
- package/dist/oauth.js +90 -8
- package/dist/pricing.d.ts +1 -1
- package/dist/pricing.js +67 -2
- package/dist/provider-tool-capabilities.d.ts +38 -0
- package/dist/provider-tool-capabilities.js +142 -0
- package/dist/request-context.d.ts +4 -0
- package/dist/request-context.js +16 -0
- package/dist/request-helpers.d.ts +4 -4
- package/dist/request-limits.d.ts +8 -0
- package/dist/request-limits.js +49 -0
- package/dist/secret-redaction.d.ts +3 -0
- package/dist/secret-redaction.js +53 -0
- package/dist/session-manager-pg.js +8 -5
- package/dist/session-manager.d.ts +1 -0
- package/dist/session-manager.js +2 -0
- package/dist/upstream-contracts.d.ts +27 -0
- package/dist/upstream-contracts.js +131 -0
- package/migrations/004_session_owner_principal.sql +10 -0
- package/npm-shrinkwrap.json +2 -2
- 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;
|
package/dist/approval-manager.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"';
|
package/dist/cache-stats.d.ts
CHANGED
package/dist/cache-stats.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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:
|
|
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
|
-
|
|
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 ?? "";
|