blun-king-cli 9.1.465 → 9.1.467
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.
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
const AGENT_TURNS_PATH = '/v1/agent/turns';
|
|
2
|
+
const PUBLIC_MODEL_ID = 'king';
|
|
3
|
+
const PUBLIC_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u;
|
|
4
|
+
|
|
5
|
+
class AgentApiHttpAdapterError extends Error {
|
|
6
|
+
constructor(code, message, status = 400) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'AgentApiHttpAdapterError';
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.status = status;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function adapterError(code, message, status = 400) {
|
|
15
|
+
return new AgentApiHttpAdapterError(code, message, status);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function requireTrustedId(value, label) {
|
|
19
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
20
|
+
throw adapterError('AGENT_API_UNAUTHENTICATED', `${label} is required`, 401);
|
|
21
|
+
}
|
|
22
|
+
return value.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function requirePublicId(value, label) {
|
|
26
|
+
if (typeof value !== 'string' || !PUBLIC_ID_PATTERN.test(value)) {
|
|
27
|
+
throw adapterError(
|
|
28
|
+
'AGENT_API_INVALID_ID',
|
|
29
|
+
`${label} must match ${PUBLIC_ID_PATTERN}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requireBody(value) {
|
|
36
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
37
|
+
throw adapterError('AGENT_API_INVALID_BODY', 'request body must be a JSON object');
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function requireKing(value) {
|
|
43
|
+
if (value !== PUBLIC_MODEL_ID) {
|
|
44
|
+
throw adapterError('AGENT_API_INVALID_MODEL', 'model must be king');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizePrompt(value) {
|
|
49
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
50
|
+
throw adapterError('AGENT_API_INVALID_INPUT', 'input must be a non-empty string');
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeTools(value) {
|
|
56
|
+
if (value === undefined) return [];
|
|
57
|
+
if (!Array.isArray(value)) {
|
|
58
|
+
throw adapterError('AGENT_API_INVALID_TOOLS', 'tools must be an array');
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function decodeRequestId(value) {
|
|
64
|
+
let decoded;
|
|
65
|
+
try {
|
|
66
|
+
decoded = decodeURIComponent(value);
|
|
67
|
+
} catch {
|
|
68
|
+
throw adapterError('AGENT_API_INVALID_ID', 'request ID is not valid URL encoding');
|
|
69
|
+
}
|
|
70
|
+
return requirePublicId(decoded, 'request ID');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function routeFor(method, rawPath) {
|
|
74
|
+
const path = String(rawPath ?? '').split('?', 1)[0];
|
|
75
|
+
if (method === 'POST' && path === AGENT_TURNS_PATH) return { kind: 'start' };
|
|
76
|
+
if (!path.startsWith(`${AGENT_TURNS_PATH}/`)) {
|
|
77
|
+
if (path.startsWith('/v1/agent/')) return { kind: 'unknown' };
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const tail = path.slice(AGENT_TURNS_PATH.length + 1);
|
|
82
|
+
const toolSuffix = '/tool-results';
|
|
83
|
+
if (method === 'POST' && tail.endsWith(toolSuffix)) {
|
|
84
|
+
return {
|
|
85
|
+
kind: 'continue',
|
|
86
|
+
requestId: decodeRequestId(tail.slice(0, -toolSuffix.length)),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (method === 'DELETE' && !tail.includes('/')) {
|
|
90
|
+
return { kind: 'cancel', requestId: decodeRequestId(tail) };
|
|
91
|
+
}
|
|
92
|
+
return { kind: 'unknown' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function jsonResponse(status, body) {
|
|
96
|
+
return {
|
|
97
|
+
status,
|
|
98
|
+
headers: { 'content-type': 'application/json' },
|
|
99
|
+
body,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function publicCompletion(response) {
|
|
104
|
+
if (response === null || typeof response !== 'object' || Array.isArray(response)) {
|
|
105
|
+
throw adapterError('AGENT_API_INVALID_CONTROLLER_RESPONSE', 'Agent response is invalid', 502);
|
|
106
|
+
}
|
|
107
|
+
return { ...response, model: PUBLIC_MODEL_ID };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function publicErrorResponse(error) {
|
|
111
|
+
if (error instanceof AgentApiHttpAdapterError) {
|
|
112
|
+
return jsonResponse(error.status, {
|
|
113
|
+
error: {
|
|
114
|
+
type: 'invalid_request_error',
|
|
115
|
+
code: error.code,
|
|
116
|
+
message: error.message,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const code = typeof error?.code === 'string' ? error.code : '';
|
|
122
|
+
if (code === 'AGENT_API_TURN_SCOPE_MISMATCH') {
|
|
123
|
+
return jsonResponse(403, {
|
|
124
|
+
error: {
|
|
125
|
+
type: 'permission_error',
|
|
126
|
+
code,
|
|
127
|
+
message: 'Agent turn belongs to a different request.',
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (code === 'AGENT_API_SESSION_BUSY' || code === 'AGENT_API_TURN_NOT_ACTIVE') {
|
|
132
|
+
return jsonResponse(409, {
|
|
133
|
+
error: {
|
|
134
|
+
type: 'conflict_error',
|
|
135
|
+
code,
|
|
136
|
+
message: 'Agent session is not ready for this operation.',
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (code.startsWith('AGENT_API_INVALID_')) {
|
|
141
|
+
return jsonResponse(400, {
|
|
142
|
+
error: {
|
|
143
|
+
type: 'invalid_request_error',
|
|
144
|
+
code,
|
|
145
|
+
message: 'Agent request is invalid.',
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (code === 'AGENT_API_TURN_FAILED') {
|
|
150
|
+
return jsonResponse(502, {
|
|
151
|
+
error: {
|
|
152
|
+
type: 'upstream_error',
|
|
153
|
+
code,
|
|
154
|
+
message: 'Agent turn failed.',
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return jsonResponse(500, {
|
|
159
|
+
error: {
|
|
160
|
+
type: 'internal_error',
|
|
161
|
+
code: 'AGENT_API_INTERNAL',
|
|
162
|
+
message: 'Agent request failed.',
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function scopeKey(sessionId, requestId) {
|
|
168
|
+
return `${sessionId}\0${requestId}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function responseNeedsToolResult(response) {
|
|
172
|
+
return response?.choices?.[0]?.finish_reason === 'tool_calls';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validateController(controller) {
|
|
176
|
+
if (controller === null || typeof controller !== 'object') {
|
|
177
|
+
throw new TypeError('controller is required');
|
|
178
|
+
}
|
|
179
|
+
for (const method of ['startTurn', 'continueTurn', 'cancelTurn']) {
|
|
180
|
+
if (typeof controller[method] !== 'function') {
|
|
181
|
+
throw new TypeError(`controller.${method} must be a function`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return controller;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function createAgentApiHttpAdapter(options = {}) {
|
|
188
|
+
const controller = validateController(options.controller);
|
|
189
|
+
if (typeof options.resolveSession !== 'function') {
|
|
190
|
+
throw new TypeError('resolveSession must be a function');
|
|
191
|
+
}
|
|
192
|
+
const owners = new Map();
|
|
193
|
+
|
|
194
|
+
function requireOwner(principalId, sessionId, requestId) {
|
|
195
|
+
const key = scopeKey(sessionId, requestId);
|
|
196
|
+
const owner = owners.get(key);
|
|
197
|
+
if (owner === undefined) {
|
|
198
|
+
throw adapterError(
|
|
199
|
+
'AGENT_API_TURN_NOT_ACTIVE',
|
|
200
|
+
'Agent turn is not active.',
|
|
201
|
+
409,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (owner !== principalId) {
|
|
205
|
+
throw adapterError(
|
|
206
|
+
'AGENT_API_FORBIDDEN',
|
|
207
|
+
'Agent turn belongs to a different account.',
|
|
208
|
+
403,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return key;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function trackScope(key, principalId, response) {
|
|
215
|
+
if (responseNeedsToolResult(response)) owners.set(key, principalId);
|
|
216
|
+
else owners.delete(key);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function handleStart(input, principalId) {
|
|
220
|
+
const body = requireBody(input.body);
|
|
221
|
+
requireKing(body.model);
|
|
222
|
+
if (body.stream === true) {
|
|
223
|
+
throw adapterError(
|
|
224
|
+
'AGENT_API_STREAMING_UNSUPPORTED',
|
|
225
|
+
'streaming is not available on Agent turns yet',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const sessionId = requirePublicId(body.session_id, 'session_id');
|
|
229
|
+
const requestId = requirePublicId(body.request_id, 'request_id');
|
|
230
|
+
const session = await options.resolveSession({
|
|
231
|
+
principalId,
|
|
232
|
+
sessionId,
|
|
233
|
+
create: true,
|
|
234
|
+
});
|
|
235
|
+
if (session?.id !== sessionId) {
|
|
236
|
+
throw adapterError(
|
|
237
|
+
'AGENT_API_SESSION_SCOPE_MISMATCH',
|
|
238
|
+
'resolved session does not match session_id',
|
|
239
|
+
403,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const response = publicCompletion(await controller.startTurn({
|
|
243
|
+
session,
|
|
244
|
+
requestId,
|
|
245
|
+
prompt: normalizePrompt(body.input),
|
|
246
|
+
tools: normalizeTools(body.tools),
|
|
247
|
+
}));
|
|
248
|
+
trackScope(scopeKey(sessionId, requestId), principalId, response);
|
|
249
|
+
return jsonResponse(200, response);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function handleContinue(input, principalId, requestId) {
|
|
253
|
+
const body = requireBody(input.body);
|
|
254
|
+
const sessionId = requirePublicId(body.session_id, 'session_id');
|
|
255
|
+
const key = requireOwner(principalId, sessionId, requestId);
|
|
256
|
+
const response = publicCompletion(await controller.continueTurn({
|
|
257
|
+
sessionId,
|
|
258
|
+
requestId,
|
|
259
|
+
toolResults: body.tool_results,
|
|
260
|
+
}));
|
|
261
|
+
trackScope(key, principalId, response);
|
|
262
|
+
return jsonResponse(200, response);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function handleCancel(input, principalId, requestId) {
|
|
266
|
+
const body = requireBody(input.body ?? {});
|
|
267
|
+
const sessionId = requirePublicId(body.session_id, 'session_id');
|
|
268
|
+
const key = requireOwner(principalId, sessionId, requestId);
|
|
269
|
+
await controller.cancelTurn({
|
|
270
|
+
sessionId,
|
|
271
|
+
requestId,
|
|
272
|
+
reason: typeof body.reason === 'string' ? body.reason : undefined,
|
|
273
|
+
});
|
|
274
|
+
owners.delete(key);
|
|
275
|
+
return jsonResponse(200, {
|
|
276
|
+
id: `agent-${requestId}`,
|
|
277
|
+
object: 'agent.turn.cancelled',
|
|
278
|
+
model: PUBLIC_MODEL_ID,
|
|
279
|
+
cancelled: true,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function handleRequest(input) {
|
|
284
|
+
if (input === null || typeof input !== 'object') return null;
|
|
285
|
+
const method = String(input.method ?? '').toUpperCase();
|
|
286
|
+
let route;
|
|
287
|
+
try {
|
|
288
|
+
route = routeFor(method, input.path);
|
|
289
|
+
if (route === null) return null;
|
|
290
|
+
if (route.kind === 'unknown') {
|
|
291
|
+
return jsonResponse(404, {
|
|
292
|
+
error: {
|
|
293
|
+
type: 'invalid_request_error',
|
|
294
|
+
code: 'AGENT_API_ROUTE_NOT_FOUND',
|
|
295
|
+
message: 'Agent API route not found.',
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
const principalId = requireTrustedId(input.principalId, 'principalId');
|
|
300
|
+
if (route.kind === 'start') return await handleStart(input, principalId);
|
|
301
|
+
if (route.kind === 'continue') {
|
|
302
|
+
return await handleContinue(input, principalId, route.requestId);
|
|
303
|
+
}
|
|
304
|
+
return await handleCancel(input, principalId, route.requestId);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return publicErrorResponse(error);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return Object.freeze({ handleRequest });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export {
|
|
314
|
+
AGENT_TURNS_PATH,
|
|
315
|
+
AgentApiHttpAdapterError,
|
|
316
|
+
PUBLIC_MODEL_ID,
|
|
317
|
+
createAgentApiHttpAdapter,
|
|
318
|
+
};
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { createAgentApiRuntime } from './agent-api-runtime.mjs';
|
|
2
|
+
import usageJournalModule from './agent-api-usage-journal.cjs';
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
createAgentApiTaskId,
|
|
6
|
+
createAgentApiUsageJournal,
|
|
7
|
+
} = usageJournalModule;
|
|
2
8
|
|
|
3
9
|
const MAIN_AGENT_ID = 'main';
|
|
4
10
|
const PUBLIC_MODEL_ID = 'king';
|
|
@@ -116,6 +122,7 @@ function openAiEnvelope(turn, message, finishReason) {
|
|
|
116
122
|
}],
|
|
117
123
|
blun: {
|
|
118
124
|
mode: 'agent',
|
|
125
|
+
task_id: turn.taskId,
|
|
119
126
|
session_id: turn.sessionId,
|
|
120
127
|
request_id: turn.requestId,
|
|
121
128
|
},
|
|
@@ -149,6 +156,8 @@ function completedResponse(turn, event) {
|
|
|
149
156
|
|
|
150
157
|
function createAgentApiTurnController(options = {}) {
|
|
151
158
|
const runtime = options.runtime ?? createAgentApiRuntime(options.runtimeOptions);
|
|
159
|
+
const usageJournal = options.usageJournal ?? createAgentApiUsageJournal();
|
|
160
|
+
const taskIdFactory = options.taskIdFactory ?? createAgentApiTaskId;
|
|
152
161
|
const publicModel = options.publicModel ?? PUBLIC_MODEL_ID;
|
|
153
162
|
const now = options.now ?? Date.now;
|
|
154
163
|
const activeTurns = new Map();
|
|
@@ -172,7 +181,7 @@ function createAgentApiTurnController(options = {}) {
|
|
|
172
181
|
return turn;
|
|
173
182
|
}
|
|
174
183
|
|
|
175
|
-
async function closeBinding(turn, kind, reason) {
|
|
184
|
+
async function closeBinding(turn, kind, reason, status = 'failed') {
|
|
176
185
|
if (turn.closed) return;
|
|
177
186
|
turn.closed = true;
|
|
178
187
|
activeTurns.delete(turn.sessionId);
|
|
@@ -182,6 +191,12 @@ function createAgentApiTurnController(options = {}) {
|
|
|
182
191
|
if (kind === 'finish') {
|
|
183
192
|
try {
|
|
184
193
|
await turn.binding.finish();
|
|
194
|
+
usageJournal.finishTask({
|
|
195
|
+
taskId: turn.taskId,
|
|
196
|
+
requestId: turn.requestId,
|
|
197
|
+
sessionId: turn.sessionId,
|
|
198
|
+
status,
|
|
199
|
+
});
|
|
185
200
|
return;
|
|
186
201
|
} catch (error) {
|
|
187
202
|
try {
|
|
@@ -189,10 +204,38 @@ function createAgentApiTurnController(options = {}) {
|
|
|
189
204
|
} catch {
|
|
190
205
|
// Preserve the original finish error.
|
|
191
206
|
}
|
|
207
|
+
try {
|
|
208
|
+
usageJournal.finishTask({
|
|
209
|
+
taskId: turn.taskId,
|
|
210
|
+
requestId: turn.requestId,
|
|
211
|
+
sessionId: turn.sessionId,
|
|
212
|
+
status: 'failed',
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
// Preserve the original finish error.
|
|
216
|
+
}
|
|
192
217
|
throw error;
|
|
193
218
|
}
|
|
194
219
|
}
|
|
195
|
-
|
|
220
|
+
let abortError;
|
|
221
|
+
try {
|
|
222
|
+
await turn.binding.abort(reason ?? 'Agent API turn aborted');
|
|
223
|
+
} catch (error) {
|
|
224
|
+
abortError = error;
|
|
225
|
+
}
|
|
226
|
+
let journalError;
|
|
227
|
+
try {
|
|
228
|
+
usageJournal.finishTask({
|
|
229
|
+
taskId: turn.taskId,
|
|
230
|
+
requestId: turn.requestId,
|
|
231
|
+
sessionId: turn.sessionId,
|
|
232
|
+
status,
|
|
233
|
+
});
|
|
234
|
+
} catch (error) {
|
|
235
|
+
journalError = error;
|
|
236
|
+
}
|
|
237
|
+
if (abortError !== undefined) throw abortError;
|
|
238
|
+
if (journalError !== undefined) throw journalError;
|
|
196
239
|
}
|
|
197
240
|
|
|
198
241
|
function settleTerminal(turn, event) {
|
|
@@ -223,6 +266,23 @@ function createAgentApiTurnController(options = {}) {
|
|
|
223
266
|
}
|
|
224
267
|
if (turn.turnId === undefined || event.turnId !== turn.turnId) return;
|
|
225
268
|
switch (event.type) {
|
|
269
|
+
case 'turn.step.completed':
|
|
270
|
+
try {
|
|
271
|
+
usageJournal.recordModelCall({
|
|
272
|
+
taskId: turn.taskId,
|
|
273
|
+
requestId: turn.requestId,
|
|
274
|
+
sessionId: turn.sessionId,
|
|
275
|
+
callId: event.stepId ?? `${turn.turnId}:${event.step}`,
|
|
276
|
+
usage: event.usage ?? null,
|
|
277
|
+
});
|
|
278
|
+
} catch (error) {
|
|
279
|
+
settleTerminal(turn, {
|
|
280
|
+
type: 'turn.error',
|
|
281
|
+
reason: 'failed',
|
|
282
|
+
error,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
226
286
|
case 'turn.step.retrying':
|
|
227
287
|
turn.assistantText = '';
|
|
228
288
|
return;
|
|
@@ -268,7 +328,7 @@ function createAgentApiTurnController(options = {}) {
|
|
|
268
328
|
}
|
|
269
329
|
|
|
270
330
|
const response = completedResponse(turn, outcome.event);
|
|
271
|
-
await closeBinding(turn, 'finish');
|
|
331
|
+
await closeBinding(turn, 'finish', undefined, 'completed');
|
|
272
332
|
return response;
|
|
273
333
|
}
|
|
274
334
|
|
|
@@ -293,6 +353,7 @@ function createAgentApiTurnController(options = {}) {
|
|
|
293
353
|
requestId,
|
|
294
354
|
tools: input.tools ?? [],
|
|
295
355
|
});
|
|
356
|
+
const taskId = requireId(taskIdFactory(sessionId, requestId), 'taskId');
|
|
296
357
|
const turn = {
|
|
297
358
|
assistantText: '',
|
|
298
359
|
binding,
|
|
@@ -302,11 +363,22 @@ function createAgentApiTurnController(options = {}) {
|
|
|
302
363
|
requestId,
|
|
303
364
|
session: input.session,
|
|
304
365
|
sessionId,
|
|
366
|
+
taskId,
|
|
305
367
|
terminal: createDeferred(),
|
|
306
368
|
terminalSettled: false,
|
|
307
369
|
turnId: undefined,
|
|
308
370
|
unsubscribe: undefined,
|
|
309
371
|
};
|
|
372
|
+
try {
|
|
373
|
+
usageJournal.startTask({ taskId, requestId, sessionId });
|
|
374
|
+
} catch (error) {
|
|
375
|
+
try {
|
|
376
|
+
await binding.abort('Agent API usage task could not be started');
|
|
377
|
+
} catch {
|
|
378
|
+
// Preserve the journal error.
|
|
379
|
+
}
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
310
382
|
activeTurns.set(sessionId, turn);
|
|
311
383
|
turn.unsubscribe = installEventListener(turn);
|
|
312
384
|
|
|
@@ -362,7 +434,7 @@ function createAgentApiTurnController(options = {}) {
|
|
|
362
434
|
: 'Agent API turn cancelled';
|
|
363
435
|
const outcomes = await Promise.allSettled([
|
|
364
436
|
turn.session.cancel(),
|
|
365
|
-
closeBinding(turn, 'abort', reason),
|
|
437
|
+
closeBinding(turn, 'abort', reason, 'cancelled'),
|
|
366
438
|
]);
|
|
367
439
|
const failure = outcomes.find((outcome) => outcome.status === 'rejected');
|
|
368
440
|
if (failure !== undefined) throw failure.reason;
|
|
@@ -373,6 +445,7 @@ function createAgentApiTurnController(options = {}) {
|
|
|
373
445
|
activeTurnCount: () => activeTurns.size,
|
|
374
446
|
cancelTurn,
|
|
375
447
|
continueTurn,
|
|
448
|
+
getUsageTask: (taskId) => usageJournal.getTask(taskId),
|
|
376
449
|
startTurn,
|
|
377
450
|
});
|
|
378
451
|
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const PUBLIC_MODEL_ID = 'king';
|
|
8
|
+
const JOURNAL_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
class AgentApiUsageJournalError extends Error {
|
|
11
|
+
constructor(code, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'AgentApiUsageJournalError';
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function journalError(code, message) {
|
|
19
|
+
return new AgentApiUsageJournalError(code, message);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function requireId(value, label) {
|
|
23
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
24
|
+
throw journalError('AGENT_API_USAGE_INVALID_SCOPE', `${label} must be a non-empty string`);
|
|
25
|
+
}
|
|
26
|
+
return value.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function tokenCount(value) {
|
|
30
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
31
|
+
throw journalError(
|
|
32
|
+
'AGENT_API_USAGE_INVALID_VALUE',
|
|
33
|
+
'usage token counters must be finite non-negative numbers',
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return Math.trunc(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeRawUsage(usage) {
|
|
40
|
+
if (usage === undefined || usage === null) return null;
|
|
41
|
+
if (typeof usage !== 'object' || Array.isArray(usage)) {
|
|
42
|
+
throw journalError('AGENT_API_USAGE_INVALID_VALUE', 'usage must be an object or null');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (Object.hasOwn(usage, 'prompt_tokens') || Object.hasOwn(usage, 'completion_tokens')) {
|
|
46
|
+
const promptTokens = tokenCount(usage.prompt_tokens ?? 0);
|
|
47
|
+
const completionTokens = tokenCount(usage.completion_tokens ?? 0);
|
|
48
|
+
const cachedTokens = Math.min(
|
|
49
|
+
promptTokens,
|
|
50
|
+
tokenCount(usage.prompt_tokens_details?.cached_tokens ?? 0),
|
|
51
|
+
);
|
|
52
|
+
return {
|
|
53
|
+
prompt_tokens: promptTokens,
|
|
54
|
+
completion_tokens: completionTokens,
|
|
55
|
+
prompt_tokens_details: { cached_tokens: cachedTokens },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const inputOther = tokenCount(usage.inputOther ?? 0);
|
|
60
|
+
const inputCacheRead = tokenCount(usage.inputCacheRead ?? 0);
|
|
61
|
+
const inputCacheCreation = tokenCount(usage.inputCacheCreation ?? 0);
|
|
62
|
+
const output = tokenCount(usage.output ?? 0);
|
|
63
|
+
return {
|
|
64
|
+
prompt_tokens: inputOther + inputCacheRead + inputCacheCreation,
|
|
65
|
+
completion_tokens: output,
|
|
66
|
+
prompt_tokens_details: { cached_tokens: inputCacheRead },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createAgentApiTaskId(sessionId, requestId) {
|
|
71
|
+
const session = requireId(sessionId, 'sessionId');
|
|
72
|
+
const request = requireId(requestId, 'requestId');
|
|
73
|
+
const digest = crypto.createHash('sha256').update(`${session}\0${request}`).digest('hex');
|
|
74
|
+
return `task-${digest.slice(0, 24)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function clone(value) {
|
|
78
|
+
return structuredClone(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeTimestamp(value, label) {
|
|
82
|
+
const timestamp = typeof value === 'string' ? value.trim() : '';
|
|
83
|
+
if (timestamp.length === 0 || Number.isNaN(Date.parse(timestamp))) {
|
|
84
|
+
throw journalError('AGENT_API_USAGE_INVALID_TIME', `${label} must be an ISO timestamp`);
|
|
85
|
+
}
|
|
86
|
+
return timestamp;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createAgentApiUsageJournal(options = {}) {
|
|
90
|
+
const filePath = options.filePath === undefined
|
|
91
|
+
? null
|
|
92
|
+
: path.resolve(String(options.filePath));
|
|
93
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
94
|
+
const tasks = new Map();
|
|
95
|
+
|
|
96
|
+
function taskForScope(input) {
|
|
97
|
+
const taskId = requireId(input.taskId, 'taskId');
|
|
98
|
+
const requestId = requireId(input.requestId, 'requestId');
|
|
99
|
+
const sessionId = requireId(input.sessionId, 'sessionId');
|
|
100
|
+
const task = tasks.get(taskId);
|
|
101
|
+
if (task === undefined) {
|
|
102
|
+
throw journalError('AGENT_API_USAGE_TASK_NOT_FOUND', 'usage task does not exist');
|
|
103
|
+
}
|
|
104
|
+
if (task.request_id !== requestId || task.session_id !== sessionId) {
|
|
105
|
+
throw journalError(
|
|
106
|
+
'AGENT_API_USAGE_SCOPE_MISMATCH',
|
|
107
|
+
'usage event does not belong to this task',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return task;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function appendEvent(event) {
|
|
114
|
+
if (filePath === null) return;
|
|
115
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
116
|
+
fs.appendFileSync(filePath, `${JSON.stringify(event)}\n`, {
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
mode: 0o600,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function replay(event) {
|
|
123
|
+
if (event === null || typeof event !== 'object' || event.version !== JOURNAL_VERSION) {
|
|
124
|
+
throw journalError('AGENT_API_USAGE_JOURNAL_CORRUPT', 'invalid usage journal event');
|
|
125
|
+
}
|
|
126
|
+
if (event.type === 'task.started') {
|
|
127
|
+
if (tasks.has(event.task_id)) {
|
|
128
|
+
throw journalError('AGENT_API_USAGE_JOURNAL_CORRUPT', 'duplicate task start');
|
|
129
|
+
}
|
|
130
|
+
tasks.set(event.task_id, {
|
|
131
|
+
version: JOURNAL_VERSION,
|
|
132
|
+
task_id: event.task_id,
|
|
133
|
+
request_id: event.request_id,
|
|
134
|
+
session_id: event.session_id,
|
|
135
|
+
model: PUBLIC_MODEL_ID,
|
|
136
|
+
status: 'active',
|
|
137
|
+
started_at: event.at,
|
|
138
|
+
finished_at: null,
|
|
139
|
+
calls: [],
|
|
140
|
+
callIds: new Set(),
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const task = tasks.get(event.task_id);
|
|
145
|
+
if (task === undefined) {
|
|
146
|
+
throw journalError('AGENT_API_USAGE_JOURNAL_CORRUPT', 'event references unknown task');
|
|
147
|
+
}
|
|
148
|
+
if (event.type === 'model.usage') {
|
|
149
|
+
if (task.callIds.has(event.call_id)) return;
|
|
150
|
+
task.callIds.add(event.call_id);
|
|
151
|
+
task.calls.push({
|
|
152
|
+
call_id: event.call_id,
|
|
153
|
+
sequence: event.sequence,
|
|
154
|
+
recorded_at: event.at,
|
|
155
|
+
usage: event.usage,
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (event.type === 'task.finished') {
|
|
160
|
+
task.status = event.status;
|
|
161
|
+
task.finished_at = event.at;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
throw journalError('AGENT_API_USAGE_JOURNAL_CORRUPT', 'unknown usage journal event');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function load() {
|
|
168
|
+
if (filePath === null || !fs.existsSync(filePath)) return;
|
|
169
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
170
|
+
for (const line of content.split(/\r?\n/u)) {
|
|
171
|
+
if (line.length === 0) continue;
|
|
172
|
+
let event;
|
|
173
|
+
try {
|
|
174
|
+
event = JSON.parse(line);
|
|
175
|
+
} catch {
|
|
176
|
+
throw journalError('AGENT_API_USAGE_JOURNAL_CORRUPT', 'invalid JSONL record');
|
|
177
|
+
}
|
|
178
|
+
replay(event);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function startTask(input) {
|
|
183
|
+
const taskId = requireId(input.taskId, 'taskId');
|
|
184
|
+
const requestId = requireId(input.requestId, 'requestId');
|
|
185
|
+
const sessionId = requireId(input.sessionId, 'sessionId');
|
|
186
|
+
if (tasks.has(taskId)) {
|
|
187
|
+
throw journalError('AGENT_API_USAGE_TASK_EXISTS', 'usage task already exists');
|
|
188
|
+
}
|
|
189
|
+
const event = {
|
|
190
|
+
version: JOURNAL_VERSION,
|
|
191
|
+
type: 'task.started',
|
|
192
|
+
task_id: taskId,
|
|
193
|
+
request_id: requestId,
|
|
194
|
+
session_id: sessionId,
|
|
195
|
+
at: normalizeTimestamp(input.at ?? now(), 'task start time'),
|
|
196
|
+
};
|
|
197
|
+
appendEvent(event);
|
|
198
|
+
replay(event);
|
|
199
|
+
return taskId;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function recordModelCall(input) {
|
|
203
|
+
const task = taskForScope(input);
|
|
204
|
+
if (task.status !== 'active') {
|
|
205
|
+
throw journalError('AGENT_API_USAGE_TASK_CLOSED', 'usage task is already closed');
|
|
206
|
+
}
|
|
207
|
+
const callId = requireId(input.callId, 'callId');
|
|
208
|
+
if (task.callIds.has(callId)) return false;
|
|
209
|
+
const event = {
|
|
210
|
+
version: JOURNAL_VERSION,
|
|
211
|
+
type: 'model.usage',
|
|
212
|
+
task_id: task.task_id,
|
|
213
|
+
call_id: callId,
|
|
214
|
+
sequence: task.calls.length + 1,
|
|
215
|
+
usage: normalizeRawUsage(input.usage),
|
|
216
|
+
at: normalizeTimestamp(input.at ?? now(), 'model call time'),
|
|
217
|
+
};
|
|
218
|
+
appendEvent(event);
|
|
219
|
+
replay(event);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function finishTask(input) {
|
|
224
|
+
const task = taskForScope(input);
|
|
225
|
+
const status = requireId(input.status, 'status');
|
|
226
|
+
if (!['completed', 'cancelled', 'failed'].includes(status)) {
|
|
227
|
+
throw journalError('AGENT_API_USAGE_INVALID_STATUS', 'invalid usage task status');
|
|
228
|
+
}
|
|
229
|
+
if (task.status !== 'active') return false;
|
|
230
|
+
const event = {
|
|
231
|
+
version: JOURNAL_VERSION,
|
|
232
|
+
type: 'task.finished',
|
|
233
|
+
task_id: task.task_id,
|
|
234
|
+
status,
|
|
235
|
+
at: normalizeTimestamp(input.at ?? now(), 'task finish time'),
|
|
236
|
+
};
|
|
237
|
+
appendEvent(event);
|
|
238
|
+
replay(event);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function getTask(taskId) {
|
|
243
|
+
const task = tasks.get(requireId(taskId, 'taskId'));
|
|
244
|
+
if (task === undefined) return null;
|
|
245
|
+
const { callIds: _callIds, ...snapshot } = task;
|
|
246
|
+
return clone(snapshot);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
load();
|
|
250
|
+
return Object.freeze({ finishTask, getTask, recordModelCall, startTask });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = {
|
|
254
|
+
AgentApiUsageJournalError,
|
|
255
|
+
JOURNAL_VERSION,
|
|
256
|
+
createAgentApiTaskId,
|
|
257
|
+
createAgentApiUsageJournal,
|
|
258
|
+
normalizeRawUsage,
|
|
259
|
+
};
|