@runuai/host 0.9.163 → 0.9.165
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/images/standard/Dockerfile +1 -1
- package/lib/agent-cli.ts +9 -5
- package/lib/agents/claude.ts +2 -1
- package/lib/agents/codex.ts +158 -20
- package/lib/agents/grok.ts +84 -12
- package/lib/agents/types.ts +7 -0
- package/lib/engine-accounts.ts +12 -2
- package/lib/orchestrator.ts +45 -21
- package/package.json +9 -1
- package/src/engine-models.ts +31 -0
- package/src/engine-usage.ts +104 -0
- package/src/main.ts +8 -0
- package/src/protocol.ts +10 -0
|
@@ -34,7 +34,7 @@ ENV ASDF_VERSION=v0.14.1
|
|
|
34
34
|
# freshness is requalified before any of them moves.
|
|
35
35
|
ENV UAI_CODEX_CLI_VERSION=0.157.0
|
|
36
36
|
ENV UAI_CURSOR_AGENT_VERSION=2026.08.25-3e8eec8
|
|
37
|
-
ENV UAI_GROK_CLI_VERSION=1.0.
|
|
37
|
+
ENV UAI_GROK_CLI_VERSION=1.0.67
|
|
38
38
|
|
|
39
39
|
# ---------------------------------------------------------------------------
|
|
40
40
|
# 1. System packages.
|
package/lib/agent-cli.ts
CHANGED
|
@@ -321,6 +321,7 @@ async function main() {
|
|
|
321
321
|
const handleOf = (v) => (typeof v === "string" && v.startsWith("@") ? v.slice(1) : v);
|
|
322
322
|
const launch = (a) => a.kind + (a.model ? "/" + a.model : "") + (a.effort ? "/" + a.effort : "");
|
|
323
323
|
if (action === "list") { out(await api("GET", "/api/agent/crew")); return; }
|
|
324
|
+
if (action === "status") { out(await api("GET", "/api/agent/crew-status")); return; }
|
|
324
325
|
if (action === "add") {
|
|
325
326
|
const personaId = pos[0];
|
|
326
327
|
if (!personaId || !flags.why) {
|
|
@@ -332,14 +333,15 @@ async function main() {
|
|
|
332
333
|
if (flags.engine) body.kind = flags.engine;
|
|
333
334
|
if (flags.model) body.model = flags.model;
|
|
334
335
|
if (flags.effort) body.effort = flags.effort;
|
|
336
|
+
if (flags.account) body.account = flags.account === "default" ? null : flags.account;
|
|
335
337
|
const r = await api("POST", "/api/agent/crew", body);
|
|
336
338
|
out("added @" + r.agent.id + " (" + r.agent.label + ") on " + launch(r.agent) + ". It stays idle until you dispatch work to it.");
|
|
337
339
|
return;
|
|
338
340
|
}
|
|
339
341
|
if (action === "model") {
|
|
340
342
|
const id = handleOf(pos[0] || "");
|
|
341
|
-
if (!id || !flags.why || !(flags.model || flags.engine || flags.effort)) {
|
|
342
|
-
console.error('uai: crew model needs @agent, at least one of --engine, --model and --
|
|
343
|
+
if (!id || !flags.why || !(flags.model || flags.engine || flags.effort || flags.account)) {
|
|
344
|
+
console.error('uai: crew model needs @agent, at least one of --engine, --model, --effort and --account, and --why "<reason>"');
|
|
343
345
|
process.exit(1);
|
|
344
346
|
}
|
|
345
347
|
// "default" clears the setting back to the engine's own default (null).
|
|
@@ -348,6 +350,7 @@ async function main() {
|
|
|
348
350
|
if (flags.engine) body.kind = flags.engine;
|
|
349
351
|
if (flags.model) body.model = cleared(flags.model);
|
|
350
352
|
if (flags.effort) body.effort = cleared(flags.effort);
|
|
353
|
+
if (flags.account) body.account = cleared(flags.account);
|
|
351
354
|
const r = await api("PATCH", "/api/agent/crew/" + encodeURIComponent(id), body);
|
|
352
355
|
out(r.changed
|
|
353
356
|
? "@" + id + " now runs on " + launch(r.agent) + "; its session restarts after its current turn"
|
|
@@ -364,7 +367,7 @@ async function main() {
|
|
|
364
367
|
out("removed @" + id);
|
|
365
368
|
return;
|
|
366
369
|
}
|
|
367
|
-
console.error("uai: crew commands are list, add, model and remove (Astronaut only)");
|
|
370
|
+
console.error("uai: crew commands are list, status, add, model and remove (Astronaut only)");
|
|
368
371
|
process.exit(1);
|
|
369
372
|
}
|
|
370
373
|
const key = group + " " + (action || "");
|
|
@@ -570,8 +573,9 @@ async function main() {
|
|
|
570
573
|
" uai task read <taskId> (full chat of a past task you were on)",
|
|
571
574
|
' uai dispatch @agent [@agent…] "instruction" (Secretary only)',
|
|
572
575
|
" uai crew list (Astronaut only: personas, engines, models, limits, roster)",
|
|
573
|
-
|
|
574
|
-
' uai crew
|
|
576
|
+
" uai crew status (Astronaut only: each agent's engine, model, account; account usage; credits)",
|
|
577
|
+
' uai crew add <personaId> [--as <handle>] [--engine <kind>] [--model <m>] [--effort <e>] [--account <id|label|default>] --why "<reason>"',
|
|
578
|
+
' uai crew model @agent [--engine <kind>] [--model <m|default>] [--effort <e|default>] [--account <id|label|default>] --why "<reason>"',
|
|
575
579
|
' uai crew remove @agent --why "<reason>"',
|
|
576
580
|
" uai react <heart|check|x> [--msg #id] (no --msg = the message you were last handed)",
|
|
577
581
|
" uai whoami",
|
package/lib/agents/claude.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { newId } from "../ulid";
|
|
|
25
25
|
import { createAgentTransport, type LineTransport } from "./transport";
|
|
26
26
|
import { rateLimitErrorUsage, resetSecondsToEpochMs } from "./rate-limit";
|
|
27
27
|
import { register } from "./registry";
|
|
28
|
+
import { CLAUDE_MODELS } from "../../src/engine-models";
|
|
28
29
|
import {
|
|
29
30
|
DISPATCH_INPUT_SCHEMA,
|
|
30
31
|
DISPATCH_RECIPIENT_ID_PATTERN,
|
|
@@ -49,7 +50,7 @@ import type {
|
|
|
49
50
|
// resolves these aliases to the current dated snapshots, so this list is
|
|
50
51
|
// stable across point releases. UPDATE WHEN MODELS CHANGE (new family or a
|
|
51
52
|
// retired alias). Order is display order in the cloud picker.
|
|
52
|
-
|
|
53
|
+
// The model list is host-agent/src/engine-models.ts (CLAUDE_MODELS).
|
|
53
54
|
|
|
54
55
|
// Opus ("opus" alias = Opus 4.8) stays the default; "fable" (Claude
|
|
55
56
|
// Fable 5, the Mythos-class flagship) is opt-in per agent. Update
|
package/lib/agents/codex.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* item/agentMessage/delta → message_delta
|
|
20
20
|
* item/completed (agentMessage) → message_complete
|
|
21
21
|
* item/completed (tool item) → tool_call
|
|
22
|
+
* thread/tokenUsage/updated → accumulated for its turn
|
|
22
23
|
* turn/completed → turn_complete (+ error if failed)
|
|
23
24
|
* error → error
|
|
24
25
|
*
|
|
@@ -42,32 +43,18 @@ import { attachCompatibilityKeysMatch } from "./attach-compatibility";
|
|
|
42
43
|
import { createAgentTransport, type LineTransport } from "./transport";
|
|
43
44
|
import { rateLimitErrorUsage, resetSecondsToEpochMs } from "./rate-limit";
|
|
44
45
|
import { register } from "./registry";
|
|
46
|
+
import { CODEX_MODELS } from "../../src/engine-models";
|
|
45
47
|
import type {
|
|
46
48
|
AgentEvent,
|
|
47
49
|
AgentAccountUsage,
|
|
48
50
|
AgentEventHandler,
|
|
49
51
|
AgentKind,
|
|
50
52
|
AgentSession,
|
|
53
|
+
AgentUsage,
|
|
51
54
|
RosterAgent,
|
|
52
55
|
} from "./types";
|
|
53
56
|
|
|
54
|
-
//
|
|
55
|
-
// machine-readable "list models" command — the canonical list is the
|
|
56
|
-
// interactive `codex` model picker (and `codex -m <slug>` / config.toml).
|
|
57
|
-
// Sourced from that picker; UPDATE WHEN MODELS CHANGE. Order = display order
|
|
58
|
-
// in the cloud picker. Legacy models remain reachable via config.toml.
|
|
59
|
-
const CODEX_MODELS = [
|
|
60
|
-
"gpt-6-astra",
|
|
61
|
-
"gpt-5.6-sol",
|
|
62
|
-
"gpt-5.6-terra",
|
|
63
|
-
"gpt-5.6-luna",
|
|
64
|
-
"gpt-5.5",
|
|
65
|
-
"gpt-5.4",
|
|
66
|
-
"gpt-5.4-mini",
|
|
67
|
-
"gpt-5.3-codex",
|
|
68
|
-
"gpt-5.3-codex-spark",
|
|
69
|
-
"gpt-5.2",
|
|
70
|
-
];
|
|
57
|
+
// The model list is host-agent/src/engine-models.ts (CODEX_MODELS).
|
|
71
58
|
|
|
72
59
|
// gpt-5.6-sol is Codex's current frontier coding model (the CLI default).
|
|
73
60
|
// Update alongside CODEX_MODELS. When an agent's model is null the adapter
|
|
@@ -102,6 +89,120 @@ function str(v: unknown, fallback = ""): string {
|
|
|
102
89
|
return typeof v === "string" ? v : fallback;
|
|
103
90
|
}
|
|
104
91
|
|
|
92
|
+
function nonNegativeInt(v: unknown): number | undefined {
|
|
93
|
+
return typeof v === "number" &&
|
|
94
|
+
Number.isSafeInteger(v) &&
|
|
95
|
+
v >= 0
|
|
96
|
+
? v
|
|
97
|
+
: undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface CodexTokenBreakdown {
|
|
101
|
+
inputTokens: number;
|
|
102
|
+
outputTokens: number;
|
|
103
|
+
cachedInputTokens: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function codexTokenBreakdown(value: unknown): CodexTokenBreakdown | null {
|
|
107
|
+
if (!isObj(value)) return null;
|
|
108
|
+
const inputTokens = nonNegativeInt(value.inputTokens);
|
|
109
|
+
const outputTokens = nonNegativeInt(value.outputTokens);
|
|
110
|
+
const cachedInputTokens = nonNegativeInt(value.cachedInputTokens);
|
|
111
|
+
if (
|
|
112
|
+
inputTokens === undefined ||
|
|
113
|
+
outputTokens === undefined ||
|
|
114
|
+
cachedInputTokens === undefined
|
|
115
|
+
) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
inputTokens,
|
|
120
|
+
outputTokens,
|
|
121
|
+
cachedInputTokens,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function addBreakdowns(
|
|
126
|
+
left: CodexTokenBreakdown | undefined,
|
|
127
|
+
right: CodexTokenBreakdown,
|
|
128
|
+
): CodexTokenBreakdown {
|
|
129
|
+
return {
|
|
130
|
+
inputTokens: (left?.inputTokens ?? 0) + right.inputTokens,
|
|
131
|
+
outputTokens: (left?.outputTokens ?? 0) + right.outputTokens,
|
|
132
|
+
cachedInputTokens:
|
|
133
|
+
(left?.cachedInputTokens ?? 0) + right.cachedInputTokens,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function breakdownDelta(
|
|
138
|
+
current: CodexTokenBreakdown,
|
|
139
|
+
previous: CodexTokenBreakdown,
|
|
140
|
+
): CodexTokenBreakdown | null {
|
|
141
|
+
const delta = {
|
|
142
|
+
inputTokens: current.inputTokens - previous.inputTokens,
|
|
143
|
+
outputTokens: current.outputTokens - previous.outputTokens,
|
|
144
|
+
cachedInputTokens:
|
|
145
|
+
current.cachedInputTokens - previous.cachedInputTokens,
|
|
146
|
+
};
|
|
147
|
+
return Object.values(delta).every((value) => value >= 0) ? delta : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Codex 0.157 emits one token update after every model call. `last` is that
|
|
152
|
+
* call, not the whole turn; `total` is the running thread total. A tool-using
|
|
153
|
+
* turn therefore emits several updates. Seed an unseen thread from `last`,
|
|
154
|
+
* then add positive `total` deltas so repeated snapshots add zero. Usage is
|
|
155
|
+
* kept by turn id and consumed exactly once at its turn/completed boundary.
|
|
156
|
+
*/
|
|
157
|
+
class CodexTurnUsage {
|
|
158
|
+
private readonly threadTotals = new Map<string, CodexTokenBreakdown>();
|
|
159
|
+
private readonly turns = new Map<string, CodexTokenBreakdown>();
|
|
160
|
+
|
|
161
|
+
observe(params: unknown): void {
|
|
162
|
+
if (!isObj(params)) return;
|
|
163
|
+
const threadId = str(params.threadId);
|
|
164
|
+
const turnId = str(params.turnId);
|
|
165
|
+
const tokenUsage = isObj(params.tokenUsage) ? params.tokenUsage : null;
|
|
166
|
+
if (!threadId || !turnId || !tokenUsage) return;
|
|
167
|
+
const last = codexTokenBreakdown(tokenUsage.last);
|
|
168
|
+
const total = codexTokenBreakdown(tokenUsage.total);
|
|
169
|
+
if (!last || !total) return;
|
|
170
|
+
|
|
171
|
+
const previousTotal = this.threadTotals.get(threadId);
|
|
172
|
+
const contribution = previousTotal
|
|
173
|
+
? breakdownDelta(total, previousTotal) ?? last
|
|
174
|
+
: last;
|
|
175
|
+
this.threadTotals.set(threadId, total);
|
|
176
|
+
this.turns.set(
|
|
177
|
+
turnId,
|
|
178
|
+
addBreakdowns(this.turns.get(turnId), contribution),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
complete(params: unknown, model: string | undefined): AgentUsage | undefined {
|
|
183
|
+
const p = isObj(params) ? params : {};
|
|
184
|
+
const turn = isObj(p.turn) ? p.turn : {};
|
|
185
|
+
const turnId = str(turn.id);
|
|
186
|
+
if (!turnId) return undefined;
|
|
187
|
+
const usage = this.turns.get(turnId);
|
|
188
|
+
this.turns.delete(turnId);
|
|
189
|
+
if (!usage) return undefined;
|
|
190
|
+
return {
|
|
191
|
+
...(model ? { model } : {}),
|
|
192
|
+
// OpenAI includes cached input inside inputTokens. Uai's shared shape,
|
|
193
|
+
// like Claude's, keeps fresh input and cache reads in separate columns.
|
|
194
|
+
// Codex 0.157 does not say whether cacheWriteInputTokens is already in
|
|
195
|
+
// inputTokens. Leave it in fresh input and do not also report a cache
|
|
196
|
+
// create until the protocol makes that relationship explicit.
|
|
197
|
+
inputTokens: Math.max(0, usage.inputTokens - usage.cachedInputTokens),
|
|
198
|
+
cacheReadTokens: usage.cachedInputTokens,
|
|
199
|
+
// reasoningOutputTokens is a subset of outputTokens in this protocol
|
|
200
|
+
// (totalTokens = inputTokens + outputTokens), so do not add it again.
|
|
201
|
+
outputTokens: usage.outputTokens,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
105
206
|
/** `item/completed` item types rendered as tool-call cards. */
|
|
106
207
|
const TOOL_ITEM_TYPES = new Set([
|
|
107
208
|
"commandExecution",
|
|
@@ -111,6 +212,10 @@ const TOOL_ITEM_TYPES = new Set([
|
|
|
111
212
|
"webSearch",
|
|
112
213
|
]);
|
|
113
214
|
|
|
215
|
+
function turnComplete(usage: AgentUsage | undefined): AgentEvent {
|
|
216
|
+
return usage ? { type: "turn_complete", usage } : { type: "turn_complete" };
|
|
217
|
+
}
|
|
218
|
+
|
|
114
219
|
/**
|
|
115
220
|
* Map one Codex JSON-RPC *notification* (method + params) to AgentEvents.
|
|
116
221
|
* Pure and total — an unrecognised method yields [].
|
|
@@ -119,6 +224,7 @@ export function mapCodexNotification(
|
|
|
119
224
|
method: string,
|
|
120
225
|
params: unknown,
|
|
121
226
|
now: number = Date.now(),
|
|
227
|
+
turnUsage?: AgentUsage,
|
|
122
228
|
): AgentEvent[] {
|
|
123
229
|
const p = isObj(params) ? params : {};
|
|
124
230
|
|
|
@@ -170,10 +276,10 @@ export function mapCodexNotification(
|
|
|
170
276
|
: { resetsAt: accountUsage.resetsAt }),
|
|
171
277
|
}
|
|
172
278
|
: { type: "error", message },
|
|
173
|
-
|
|
279
|
+
turnComplete(turnUsage),
|
|
174
280
|
];
|
|
175
281
|
}
|
|
176
|
-
return [
|
|
282
|
+
return [turnComplete(turnUsage)];
|
|
177
283
|
}
|
|
178
284
|
|
|
179
285
|
if (method === "error") {
|
|
@@ -483,6 +589,8 @@ export class CodexSession implements AgentSession {
|
|
|
483
589
|
private turnRunning = false;
|
|
484
590
|
private lastRateLimitsReadAt = 0;
|
|
485
591
|
private rateLimitsTimer: ReturnType<typeof setTimeout> | null = null;
|
|
592
|
+
private readonly turnUsage = new CodexTurnUsage();
|
|
593
|
+
private actualModel: string | undefined;
|
|
486
594
|
|
|
487
595
|
constructor(args: {
|
|
488
596
|
taskId: string;
|
|
@@ -652,6 +760,13 @@ export class CodexSession implements AgentSession {
|
|
|
652
760
|
// Notification (method, no id).
|
|
653
761
|
if ("method" in msg) {
|
|
654
762
|
const method = str(msg.method);
|
|
763
|
+
if (method === "thread/tokenUsage/updated") {
|
|
764
|
+
this.turnUsage.observe(msg.params);
|
|
765
|
+
}
|
|
766
|
+
const completedUsage =
|
|
767
|
+
method === "turn/completed"
|
|
768
|
+
? this.turnUsage.complete(msg.params, this.actualModel)
|
|
769
|
+
: undefined;
|
|
655
770
|
if (method === "turn/completed") {
|
|
656
771
|
this.turnRunning = false;
|
|
657
772
|
this.clearRateLimitsTimer();
|
|
@@ -664,9 +779,17 @@ export class CodexSession implements AgentSession {
|
|
|
664
779
|
if (isObj(thread) && typeof thread.id === "string") {
|
|
665
780
|
this.threadId = thread.id;
|
|
666
781
|
this.persistThreadId(thread.id);
|
|
782
|
+
if (typeof thread.model === "string" && thread.model.length > 0) {
|
|
783
|
+
this.actualModel = thread.model;
|
|
784
|
+
}
|
|
667
785
|
}
|
|
668
786
|
}
|
|
669
|
-
for (const ev of mapCodexNotification(
|
|
787
|
+
for (const ev of mapCodexNotification(
|
|
788
|
+
method,
|
|
789
|
+
msg.params,
|
|
790
|
+
Date.now(),
|
|
791
|
+
completedUsage,
|
|
792
|
+
)) {
|
|
670
793
|
this.emit(ev);
|
|
671
794
|
}
|
|
672
795
|
}
|
|
@@ -833,8 +956,14 @@ export class CodexSession implements AgentSession {
|
|
|
833
956
|
threadParams.developerInstructions = this.systemPreamble;
|
|
834
957
|
}
|
|
835
958
|
const result = await this.request("thread/start", threadParams);
|
|
959
|
+
if (isObj(result) && typeof result.model === "string") {
|
|
960
|
+
this.actualModel = result.model;
|
|
961
|
+
}
|
|
836
962
|
if (isObj(result) && isObj(result.thread)) {
|
|
837
963
|
const id = str(result.thread.id) || this.threadId;
|
|
964
|
+
if (!this.actualModel && typeof result.thread.model === "string") {
|
|
965
|
+
this.actualModel = result.thread.model;
|
|
966
|
+
}
|
|
838
967
|
this.threadId = id;
|
|
839
968
|
if (id) this.persistThreadId(id);
|
|
840
969
|
}
|
|
@@ -861,6 +990,15 @@ export class CodexSession implements AgentSession {
|
|
|
861
990
|
isObj(result) && isObj(result.thread) && str(result.thread.id)
|
|
862
991
|
? str(result.thread.id)
|
|
863
992
|
: threadId;
|
|
993
|
+
if (isObj(result) && typeof result.model === "string") {
|
|
994
|
+
this.actualModel = result.model;
|
|
995
|
+
} else if (
|
|
996
|
+
isObj(result) &&
|
|
997
|
+
isObj(result.thread) &&
|
|
998
|
+
typeof result.thread.model === "string"
|
|
999
|
+
) {
|
|
1000
|
+
this.actualModel = result.thread.model;
|
|
1001
|
+
}
|
|
864
1002
|
this.threadId = id;
|
|
865
1003
|
this.persistThreadId(id);
|
|
866
1004
|
return true;
|
package/lib/agents/grok.ts
CHANGED
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Stream-json vocabulary (`--output-format streaming-json`, verified):
|
|
17
17
|
* {"type":"thought","data":"…"} → skipped (internal reasoning)
|
|
18
|
-
* {"type":"text","data":"…"}
|
|
19
|
-
* {"type":"
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* {"type":"text","data":"…"} → message_delta (streamed)
|
|
19
|
+
* {"type":"tool_call"|"tool_call_update",…} → safely skipped (no cards yet)
|
|
20
|
+
* {"type":"usage",…} → skipped (per model call)
|
|
21
|
+
* {"type":"available_commands",…} → skipped
|
|
22
|
+
* {"type":"end","stopReason","sessionId",…} → message_complete + turn usage/end
|
|
23
|
+
* Grok 1.0.67 surfaces tool calls, but tool cards are deliberately outside
|
|
24
|
+
* this adapter slice. The final `end.usage` aggregates the invocation.
|
|
23
25
|
*
|
|
24
26
|
* AUTH is the operator's Grok subscription: `~/.grok/{auth.json, config.toml}`
|
|
25
27
|
* is docker-cp'd into the container at task-up (the Codex/Kimi config-dir
|
|
@@ -43,11 +45,13 @@ import {
|
|
|
43
45
|
type AgentEnvironmentProcess,
|
|
44
46
|
} from "./proc";
|
|
45
47
|
import { register } from "./registry";
|
|
48
|
+
import { GROK_MODELS } from "../../src/engine-models";
|
|
46
49
|
import type {
|
|
47
50
|
AgentEvent,
|
|
48
51
|
AgentEventHandler,
|
|
49
52
|
AgentKind,
|
|
50
53
|
AgentSession,
|
|
54
|
+
AgentUsage,
|
|
51
55
|
RosterAgent,
|
|
52
56
|
} from "./types";
|
|
53
57
|
|
|
@@ -56,7 +60,7 @@ export const GROK_LOADER_LOGIN_ENV = "0";
|
|
|
56
60
|
|
|
57
61
|
// `grok models` lists what the account can run. grok-4.5 is the current
|
|
58
62
|
// default; UPDATE WHEN xAI CHANGES the lineup.
|
|
59
|
-
|
|
63
|
+
// The model list is host-agent/src/engine-models.ts (GROK_MODELS).
|
|
60
64
|
const GROK_DEFAULT_MODEL = "grok-4.5";
|
|
61
65
|
// Grok has no per-invocation reasoning-effort flag.
|
|
62
66
|
const GROK_EFFORTS: string[] = [];
|
|
@@ -76,9 +80,68 @@ export interface MappedGrokLine {
|
|
|
76
80
|
end?: boolean;
|
|
77
81
|
/** The session id from the end event, for `-r` on the next turn. */
|
|
78
82
|
sessionId?: string;
|
|
83
|
+
/** Per-invocation usage from the final end event. */
|
|
84
|
+
usage?: AgentUsage;
|
|
79
85
|
}
|
|
80
86
|
|
|
81
|
-
|
|
87
|
+
function nonNegativeInt(value: unknown): number | undefined {
|
|
88
|
+
return typeof value === "number" &&
|
|
89
|
+
Number.isSafeInteger(value) &&
|
|
90
|
+
value >= 0
|
|
91
|
+
? value
|
|
92
|
+
: undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function grokEndUsage(
|
|
96
|
+
msg: Record<string, unknown>,
|
|
97
|
+
model: string | undefined,
|
|
98
|
+
): AgentUsage | undefined {
|
|
99
|
+
if (typeof msg.usage !== "object" || msg.usage === null) return undefined;
|
|
100
|
+
const raw = msg.usage as Record<string, unknown>;
|
|
101
|
+
const inputTokens = nonNegativeInt(raw.input_tokens);
|
|
102
|
+
const cacheReadTokens = nonNegativeInt(raw.cache_read_input_tokens);
|
|
103
|
+
const cacheCreateTokens = nonNegativeInt(raw.cache_creation_input_tokens);
|
|
104
|
+
const outputTokens = nonNegativeInt(raw.output_tokens);
|
|
105
|
+
const reasoningTokens = nonNegativeInt(raw.reasoning_tokens);
|
|
106
|
+
if (
|
|
107
|
+
inputTokens === undefined ||
|
|
108
|
+
cacheReadTokens === undefined ||
|
|
109
|
+
cacheCreateTokens === undefined ||
|
|
110
|
+
outputTokens === undefined ||
|
|
111
|
+
reasoningTokens === undefined
|
|
112
|
+
) {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const reportedCost = msg.total_cost_usd;
|
|
116
|
+
const costUsd =
|
|
117
|
+
typeof reportedCost === "number" &&
|
|
118
|
+
Number.isFinite(reportedCost) &&
|
|
119
|
+
reportedCost >= 0
|
|
120
|
+
? reportedCost
|
|
121
|
+
: undefined;
|
|
122
|
+
return {
|
|
123
|
+
// `modelUsage` exposes the backend build id (for example
|
|
124
|
+
// grok-4.5-build), while `model` is the semantic id passed to `-m` and
|
|
125
|
+
// therefore the id Uai's model guide resolves.
|
|
126
|
+
...(model ? { model } : {}),
|
|
127
|
+
// Headless input_tokens is already uncached; both cache buckets are
|
|
128
|
+
// additive according to the 1.0.67 headless contract.
|
|
129
|
+
inputTokens,
|
|
130
|
+
cacheReadTokens,
|
|
131
|
+
cacheCreateTokens,
|
|
132
|
+
// The documented total excludes reasoning_tokens even though it includes
|
|
133
|
+
// output_tokens. Uai has no separate reasoning column, so include that
|
|
134
|
+
// distinct billed output once here.
|
|
135
|
+
outputTokens: outputTokens + reasoningTokens,
|
|
136
|
+
// Missing cost means unreported/partial, never free.
|
|
137
|
+
...(costUsd === undefined ? {} : { costUsd }),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function mapGrokLine(
|
|
142
|
+
line: string,
|
|
143
|
+
model?: string,
|
|
144
|
+
): MappedGrokLine {
|
|
82
145
|
const trimmed = line.trim();
|
|
83
146
|
if (!trimmed.startsWith("{")) return {};
|
|
84
147
|
let msg: Record<string, unknown>;
|
|
@@ -91,9 +154,11 @@ export function mapGrokLine(line: string): MappedGrokLine {
|
|
|
91
154
|
return { textDelta: msg.data };
|
|
92
155
|
}
|
|
93
156
|
if (msg.type === "end") {
|
|
157
|
+
const usage = grokEndUsage(msg, model);
|
|
94
158
|
return {
|
|
95
159
|
end: true,
|
|
96
160
|
sessionId: typeof msg.sessionId === "string" ? msg.sessionId : undefined,
|
|
161
|
+
...(usage ? { usage } : {}),
|
|
97
162
|
};
|
|
98
163
|
}
|
|
99
164
|
// "thought" (reasoning) and any other types: no user-facing event.
|
|
@@ -195,9 +260,10 @@ export class GrokSession implements AgentSession {
|
|
|
195
260
|
// the same reasoning as --permission-mode bypassPermissions above
|
|
196
261
|
// (live 2026-09-08, first Grok agents with MCP servers).
|
|
197
262
|
GROK_FOLDER_TRUST: "0",
|
|
198
|
-
//
|
|
199
|
-
// reuses
|
|
200
|
-
// tasks refresh per command, so
|
|
263
|
+
// Grok 1.0.67 still enables login-shell capture by default and honors
|
|
264
|
+
// GROK_LOGIN_ENV. That capture reuses static state for
|
|
265
|
+
// LocalNonPersistent commands; loader tasks refresh per command, so
|
|
266
|
+
// keep the stale snapshot path disabled.
|
|
201
267
|
...(this.environment.descriptor.envMode === "loader"
|
|
202
268
|
? { GROK_LOGIN_ENV: GROK_LOADER_LOGIN_ENV }
|
|
203
269
|
: {}),
|
|
@@ -214,6 +280,7 @@ export class GrokSession implements AgentSession {
|
|
|
214
280
|
|
|
215
281
|
let acc = "";
|
|
216
282
|
let sawText = false;
|
|
283
|
+
let turnUsage: AgentUsage | undefined;
|
|
217
284
|
let buf = "";
|
|
218
285
|
const consume = (chunk: string): void => {
|
|
219
286
|
buf += chunk;
|
|
@@ -221,8 +288,9 @@ export class GrokSession implements AgentSession {
|
|
|
221
288
|
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
222
289
|
const line = buf.slice(0, nl);
|
|
223
290
|
buf = buf.slice(nl + 1);
|
|
224
|
-
const m = mapGrokLine(line);
|
|
291
|
+
const m = mapGrokLine(line, this.model);
|
|
225
292
|
if (m.sessionId) this.sessionId = m.sessionId;
|
|
293
|
+
if (m.end) turnUsage = m.usage;
|
|
226
294
|
if (typeof m.textDelta === "string") {
|
|
227
295
|
sawText = true;
|
|
228
296
|
acc += m.textDelta;
|
|
@@ -252,7 +320,11 @@ export class GrokSession implements AgentSession {
|
|
|
252
320
|
}
|
|
253
321
|
// Finalize the streamed message (no-op text if the turn produced none).
|
|
254
322
|
if (sawText) this.emit({ type: "message_complete", text: acc });
|
|
255
|
-
this.emit(
|
|
323
|
+
this.emit(
|
|
324
|
+
turnUsage
|
|
325
|
+
? { type: "turn_complete", usage: turnUsage }
|
|
326
|
+
: { type: "turn_complete" },
|
|
327
|
+
);
|
|
256
328
|
resolve();
|
|
257
329
|
};
|
|
258
330
|
child.on("exit", (code) => finish(code));
|
package/lib/agents/types.ts
CHANGED
|
@@ -51,6 +51,13 @@ export const RosterAgent = z.object({
|
|
|
51
51
|
kind: AgentKind,
|
|
52
52
|
model: z.string().min(1).optional(),
|
|
53
53
|
effort: z.string().min(1).optional(), // CLI reasoning level, passed through
|
|
54
|
+
// ADR-157 addendum §7: preferred cloud-leased AI account.
|
|
55
|
+
accountId: z
|
|
56
|
+
.string()
|
|
57
|
+
.min(1)
|
|
58
|
+
.max(128)
|
|
59
|
+
.regex(/^[A-Za-z0-9_.:-]+$/)
|
|
60
|
+
.optional(),
|
|
54
61
|
defaultPrompt: z.string().optional(), // persona
|
|
55
62
|
initialPrompt: z.string().optional(), // first-turn instructions, this task only
|
|
56
63
|
skills: z.array(RosterSkill).optional(), // ADR-046 reference material
|
package/lib/engine-accounts.ts
CHANGED
|
@@ -558,7 +558,7 @@ export function noteEngineAccountUsed(id: string, now: number = Date.now()): voi
|
|
|
558
558
|
*/
|
|
559
559
|
export function selectAccount(
|
|
560
560
|
accounts: ResolvedEngineAccount[],
|
|
561
|
-
opts: { exclude?: Set<string>; now?: number } = {},
|
|
561
|
+
opts: { exclude?: Set<string>; now?: number; preferredId?: string } = {},
|
|
562
562
|
): ResolvedEngineAccount | null {
|
|
563
563
|
const now = opts.now ?? Date.now();
|
|
564
564
|
const exclude = opts.exclude ?? new Set<string>();
|
|
@@ -566,6 +566,10 @@ export function selectAccount(
|
|
|
566
566
|
if (candidates.length === 0) return null;
|
|
567
567
|
const lru = (a: ResolvedEngineAccount): number => state(a.id).lastUsedAt;
|
|
568
568
|
const healthy = candidates.filter((a) => accountCooldownUntil(a.id) <= now);
|
|
569
|
+
const preferred = opts.preferredId
|
|
570
|
+
? healthy.find((account) => account.id === opts.preferredId)
|
|
571
|
+
: undefined;
|
|
572
|
+
if (preferred) return preferred;
|
|
569
573
|
// Manage-v1: the list arrives in PRIORITY order (default slot, then extras
|
|
570
574
|
// by position) — the first healthy account wins, so the operator's
|
|
571
575
|
// ordering decides which account a task uses and which are fallbacks.
|
|
@@ -585,6 +589,8 @@ export function pickEngineAccount(
|
|
|
585
589
|
exclude?: Set<string>;
|
|
586
590
|
seams?: Partial<AccountSeams>;
|
|
587
591
|
now?: number;
|
|
592
|
+
/** One agent's durable preference; cooling or absent falls through. */
|
|
593
|
+
preferredId?: string;
|
|
588
594
|
/** ADR-131 D3: the task's scope; default = the host's own accounts. */
|
|
589
595
|
scope?: string;
|
|
590
596
|
} = {},
|
|
@@ -594,7 +600,11 @@ export function pickEngineAccount(
|
|
|
594
600
|
opts.seams ?? {},
|
|
595
601
|
opts.scope ?? HOST_ACCOUNT_SCOPE,
|
|
596
602
|
);
|
|
597
|
-
return selectAccount(accounts, {
|
|
603
|
+
return selectAccount(accounts, {
|
|
604
|
+
exclude: opts.exclude,
|
|
605
|
+
now: opts.now,
|
|
606
|
+
preferredId: opts.preferredId,
|
|
607
|
+
});
|
|
598
608
|
}
|
|
599
609
|
|
|
600
610
|
/**
|
package/lib/orchestrator.ts
CHANGED
|
@@ -166,6 +166,7 @@ import {
|
|
|
166
166
|
selectAccount,
|
|
167
167
|
type ResolvedEngineAccount,
|
|
168
168
|
} from "./engine-accounts";
|
|
169
|
+
import { buildEngineUsageFrame } from "../src/engine-usage";
|
|
169
170
|
import {
|
|
170
171
|
ENGINE_ACCOUNT_HOMES,
|
|
171
172
|
EngineCredentialWriteRefused,
|
|
@@ -1746,11 +1747,19 @@ export class Orchestrator {
|
|
|
1746
1747
|
channel: Channel,
|
|
1747
1748
|
kind: string,
|
|
1748
1749
|
exclude?: Set<string>,
|
|
1750
|
+
preferredId?: string,
|
|
1749
1751
|
): ResolvedEngineAccount | null {
|
|
1750
1752
|
if (!this.isLeasedChannel(channel)) {
|
|
1751
|
-
return pickEngineAccount(kind, {
|
|
1753
|
+
return pickEngineAccount(kind, {
|
|
1754
|
+
scope: channel.engineAccountScope,
|
|
1755
|
+
exclude,
|
|
1756
|
+
preferredId,
|
|
1757
|
+
});
|
|
1752
1758
|
}
|
|
1753
|
-
return selectAccount(this.engineAccounts(channel, kind), {
|
|
1759
|
+
return selectAccount(this.engineAccounts(channel, kind), {
|
|
1760
|
+
exclude,
|
|
1761
|
+
preferredId,
|
|
1762
|
+
});
|
|
1754
1763
|
}
|
|
1755
1764
|
|
|
1756
1765
|
private noteProvisionMutation(
|
|
@@ -2789,6 +2798,7 @@ export class Orchestrator {
|
|
|
2789
2798
|
if (this.isLeasedChannel(channel)) return { ...floor, ...base };
|
|
2790
2799
|
const account = pickEngineAccount(agent.kind, {
|
|
2791
2800
|
scope: channel.engineAccountScope,
|
|
2801
|
+
preferredId: agent.accountId,
|
|
2792
2802
|
});
|
|
2793
2803
|
if (!account) {
|
|
2794
2804
|
channel.accountByAgent.delete(agent.id);
|
|
@@ -3381,8 +3391,14 @@ export class Orchestrator {
|
|
|
3381
3391
|
account.engineKind === kind && isLeasedEngineAccount(account),
|
|
3382
3392
|
);
|
|
3383
3393
|
const bound = channel.accountByAgent.get(agentId);
|
|
3394
|
+
const preferredId = channel.roster.find(
|
|
3395
|
+
(candidate) => candidate.id === agentId,
|
|
3396
|
+
)?.accountId;
|
|
3384
3397
|
const kept = accounts.find((account) => account.credentialId === bound);
|
|
3385
|
-
|
|
3398
|
+
// Without a pin, preserve the live binding. With one, every fresh session
|
|
3399
|
+
// re-evaluates it: a cooling/missing pin falls through now, and wins again
|
|
3400
|
+
// automatically after its bench lifts.
|
|
3401
|
+
if (!preferredId && kept) return kept;
|
|
3386
3402
|
const picked = selectAccount(
|
|
3387
3403
|
accounts.map((account) => ({
|
|
3388
3404
|
id: account.credentialId,
|
|
@@ -3393,6 +3409,7 @@ export class Orchestrator {
|
|
|
3393
3409
|
authKind: account.authKind,
|
|
3394
3410
|
execEnv: {},
|
|
3395
3411
|
})),
|
|
3412
|
+
{ preferredId },
|
|
3396
3413
|
);
|
|
3397
3414
|
return accounts.find((account) => account.credentialId === picked?.id);
|
|
3398
3415
|
}
|
|
@@ -5002,6 +5019,24 @@ export class Orchestrator {
|
|
|
5002
5019
|
if (!identity || identity.subject !== "account") break;
|
|
5003
5020
|
const agent = channel.roster.find((candidate) => candidate.id === agentId);
|
|
5004
5021
|
if (!agent || !isEngineUsageKind(agent.kind)) break;
|
|
5022
|
+
// The session's own revision: a refreshed lease may already hold a
|
|
5023
|
+
// newer one, and its variable name says nothing about this session.
|
|
5024
|
+
const leasedAccount = leasedEnginesFor(channel.taskId)?.accounts.find(
|
|
5025
|
+
(candidate): candidate is LeasedEngineAccount =>
|
|
5026
|
+
candidate.credentialId === identity.credentialId &&
|
|
5027
|
+
candidate.revision === identity.revision &&
|
|
5028
|
+
isLeasedEngineAccount(candidate),
|
|
5029
|
+
);
|
|
5030
|
+
const frame = buildEngineUsageFrame({
|
|
5031
|
+
reportId: newId(),
|
|
5032
|
+
taskId: channel.taskId,
|
|
5033
|
+
agentId,
|
|
5034
|
+
engine: agent.kind,
|
|
5035
|
+
credentialId: identity.credentialId,
|
|
5036
|
+
revision: identity.revision,
|
|
5037
|
+
usage: event.usage,
|
|
5038
|
+
leasedAccount,
|
|
5039
|
+
});
|
|
5005
5040
|
|
|
5006
5041
|
const coverage = event.usage.coverage;
|
|
5007
5042
|
if (event.usage.status === "limited") {
|
|
@@ -5048,6 +5083,8 @@ export class Orchestrator {
|
|
|
5048
5083
|
taskId: channel.taskId,
|
|
5049
5084
|
engine: agent.kind,
|
|
5050
5085
|
credentialId: identity.credentialId,
|
|
5086
|
+
billing: frame.billing,
|
|
5087
|
+
revision: frame.revision,
|
|
5051
5088
|
status: event.usage.status,
|
|
5052
5089
|
resetsAt: event.usage.resetsAt,
|
|
5053
5090
|
usingOverage: event.usage.usingOverage,
|
|
@@ -5062,24 +5099,7 @@ export class Orchestrator {
|
|
|
5062
5099
|
) {
|
|
5063
5100
|
break;
|
|
5064
5101
|
}
|
|
5065
|
-
const delivered = this.emitEngineUsage(
|
|
5066
|
-
kind: "engine.usage",
|
|
5067
|
-
reportId: newId(),
|
|
5068
|
-
taskId: channel.taskId,
|
|
5069
|
-
agentId,
|
|
5070
|
-
engine: agent.kind,
|
|
5071
|
-
credentialId: identity.credentialId,
|
|
5072
|
-
status: event.usage.status,
|
|
5073
|
-
...(event.usage.resetsAt === undefined
|
|
5074
|
-
? {}
|
|
5075
|
-
: { resetsAt: event.usage.resetsAt }),
|
|
5076
|
-
...(event.usage.usingOverage === undefined
|
|
5077
|
-
? {}
|
|
5078
|
-
: { usingOverage: event.usage.usingOverage }),
|
|
5079
|
-
windows: event.usage.windows,
|
|
5080
|
-
source: event.usage.source,
|
|
5081
|
-
observedAt: event.usage.observedAt,
|
|
5082
|
-
});
|
|
5102
|
+
const delivered = this.emitEngineUsage(frame);
|
|
5083
5103
|
// A disconnected bridge returns false. Do not suppress the next
|
|
5084
5104
|
// identical observation unless this one actually reached the socket.
|
|
5085
5105
|
if (delivered) {
|
|
@@ -5779,6 +5799,7 @@ export class Orchestrator {
|
|
|
5779
5799
|
channel,
|
|
5780
5800
|
agent.kind,
|
|
5781
5801
|
current ? new Set([current]) : undefined,
|
|
5802
|
+
agent.accountId,
|
|
5782
5803
|
);
|
|
5783
5804
|
if (!next) return false;
|
|
5784
5805
|
|
|
@@ -6646,6 +6667,9 @@ function rosterLaunchChangeReason(
|
|
|
6646
6667
|
if (previous.kind !== next.kind) changed.push("engine");
|
|
6647
6668
|
if ((previous.model ?? "") !== (next.model ?? "")) changed.push("model");
|
|
6648
6669
|
if ((previous.effort ?? "") !== (next.effort ?? "")) changed.push("effort");
|
|
6670
|
+
if ((previous.accountId ?? "") !== (next.accountId ?? "")) {
|
|
6671
|
+
changed.push("account");
|
|
6672
|
+
}
|
|
6649
6673
|
// ADR-157 §3: the session holds a token minted for its generation, so a new
|
|
6650
6674
|
// one is a new agent under the handle (or one an older cloud release lost),
|
|
6651
6675
|
// and only a fresh launch carries a token the cloud accepts.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runuai/host",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.165",
|
|
4
4
|
"description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Uai Tech <team@runuai.com>",
|
|
@@ -72,6 +72,14 @@
|
|
|
72
72
|
"./project-env-wording": {
|
|
73
73
|
"types": "./src/project-env-wording.ts",
|
|
74
74
|
"default": "./src/project-env-wording.ts"
|
|
75
|
+
},
|
|
76
|
+
"./engine-models": {
|
|
77
|
+
"types": "./src/engine-models.ts",
|
|
78
|
+
"default": "./src/engine-models.ts"
|
|
79
|
+
},
|
|
80
|
+
"./engine-usage": {
|
|
81
|
+
"types": "./src/engine-usage.ts",
|
|
82
|
+
"default": "./src/engine-usage.ts"
|
|
75
83
|
}
|
|
76
84
|
},
|
|
77
85
|
"scripts": {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The models each engine adapter offers, by the id the runner passes its
|
|
3
|
+
* CLI: one list per engine, read by the adapters (`supportedModels`) and by
|
|
4
|
+
* the cloud's model guide (lib/model-guide.ts), whose test fails when a model
|
|
5
|
+
* here has no guide entry. Pure data: no runtime imports.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Claude Code's aliases, which follow its own model table (2.1.307:
|
|
9
|
+
// fable = Claude Fable 5.1, opus = Opus 5.5, sonnet = Sonnet 5.5,
|
|
10
|
+
// haiku = Haiku 5.5). Update alongside the CLI.
|
|
11
|
+
export const CLAUDE_MODELS: readonly string[] = ["fable", "opus", "sonnet", "haiku"];
|
|
12
|
+
|
|
13
|
+
// Codex model slugs accepted via `-c model=<slug>`. Hardcoded: Codex has no
|
|
14
|
+
// machine-readable "list models" command — the canonical list is the
|
|
15
|
+
// interactive `codex` model picker (and `codex -m <slug>` / config.toml).
|
|
16
|
+
// Sourced from that picker; UPDATE WHEN MODELS CHANGE. Order = display order
|
|
17
|
+
// in the cloud picker. Legacy models remain reachable via config.toml.
|
|
18
|
+
export const CODEX_MODELS: readonly string[] = [
|
|
19
|
+
"gpt-6-astra",
|
|
20
|
+
"gpt-5.6-sol",
|
|
21
|
+
"gpt-5.6-terra",
|
|
22
|
+
"gpt-5.6-luna",
|
|
23
|
+
"gpt-5.5",
|
|
24
|
+
"gpt-5.4",
|
|
25
|
+
"gpt-5.4-mini",
|
|
26
|
+
"gpt-5.3-codex",
|
|
27
|
+
"gpt-5.3-codex-spark",
|
|
28
|
+
"gpt-5.2",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const GROK_MODELS: readonly string[] = ["grok-4.5"];
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the secret-free account usage report sent to Uai Cloud.
|
|
3
|
+
*
|
|
4
|
+
* A leased env account carries both a variable name and its secret value in
|
|
5
|
+
* runner memory. Billing is deliberately inferred from the NAME alone. Some
|
|
6
|
+
* file-backed accounts use the same `auth.json` name for a subscription and
|
|
7
|
+
* an API key, so they carry no hint.
|
|
8
|
+
*
|
|
9
|
+
* The hint names the revision it was read from, and is given only when that is
|
|
10
|
+
* the revision the reporting session runs on: after a re-paste, a lease
|
|
11
|
+
* refresh can hand the runner newer material than an agent is still using.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { EngineUsageFrame, EngineUsageWindow } from "./protocol";
|
|
15
|
+
|
|
16
|
+
type EngineUsageBilling = NonNullable<EngineUsageFrame["billing"]>;
|
|
17
|
+
|
|
18
|
+
export interface LeasedBillingAccount {
|
|
19
|
+
credentialId: string;
|
|
20
|
+
revision: string;
|
|
21
|
+
engineKind: string;
|
|
22
|
+
authKind: "env" | "config-dir";
|
|
23
|
+
env: Readonly<Record<string, string>> | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface EngineUsageObservation {
|
|
27
|
+
status: EngineUsageFrame["status"];
|
|
28
|
+
resetsAt?: number;
|
|
29
|
+
usingOverage?: boolean;
|
|
30
|
+
windows: EngineUsageWindow[];
|
|
31
|
+
source: EngineUsageFrame["source"];
|
|
32
|
+
observedAt: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const BILLING_BY_VARIABLE: Readonly<
|
|
36
|
+
Record<string, Readonly<Record<string, EngineUsageBilling>>>
|
|
37
|
+
> = Object.freeze({
|
|
38
|
+
claude: Object.freeze({
|
|
39
|
+
// Capture files every pasted Claude secret that is NOT `sk-ant-api…` under
|
|
40
|
+
// this name (engine-accounts.ts `pastedSecretMaterial`), so the name alone
|
|
41
|
+
// also covers a token whose prefix told capture nothing, which the cloud
|
|
42
|
+
// left unknown. Subscription is still right for a report: usage is read
|
|
43
|
+
// from a session that worked, and Claude Code authenticates this variable
|
|
44
|
+
// only as an OAuth (subscription) token, so a session that ran on it was
|
|
45
|
+
// one. Do not "correct" it to unknown.
|
|
46
|
+
CLAUDE_CODE_OAUTH_TOKEN: "subscription",
|
|
47
|
+
ANTHROPIC_API_KEY: "api-key",
|
|
48
|
+
}),
|
|
49
|
+
cursor: Object.freeze({ CURSOR_API_KEY: "api-key" }),
|
|
50
|
+
grok: Object.freeze({ XAI_API_KEY: "api-key" }),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** The billing lane when one leased variable name tells it unambiguously. */
|
|
54
|
+
export function billingFromLeasedVariableName(
|
|
55
|
+
account: LeasedBillingAccount | null | undefined,
|
|
56
|
+
): EngineUsageBilling | undefined {
|
|
57
|
+
if (!account || account.authKind !== "env" || account.env === null) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const names = Object.keys(account.env);
|
|
61
|
+
if (names.length !== 1) return undefined;
|
|
62
|
+
return BILLING_BY_VARIABLE[account.engineKind]?.[names[0]!];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Construct the exact wire frame for one adapter usage observation.
|
|
66
|
+
* `revision` is the one the reporting session runs on; a leased account at
|
|
67
|
+
* any other revision gives no hint. */
|
|
68
|
+
export function buildEngineUsageFrame(input: {
|
|
69
|
+
reportId: string;
|
|
70
|
+
taskId: string;
|
|
71
|
+
agentId: string;
|
|
72
|
+
engine: EngineUsageFrame["engine"];
|
|
73
|
+
credentialId: string;
|
|
74
|
+
revision?: string;
|
|
75
|
+
usage: EngineUsageObservation;
|
|
76
|
+
leasedAccount?: LeasedBillingAccount | null;
|
|
77
|
+
}): EngineUsageFrame {
|
|
78
|
+
const { usage } = input;
|
|
79
|
+
const account =
|
|
80
|
+
input.revision !== undefined &&
|
|
81
|
+
input.leasedAccount?.credentialId === input.credentialId &&
|
|
82
|
+
input.leasedAccount.revision === input.revision &&
|
|
83
|
+
input.leasedAccount.engineKind === input.engine
|
|
84
|
+
? input.leasedAccount
|
|
85
|
+
: null;
|
|
86
|
+
const billing = billingFromLeasedVariableName(account);
|
|
87
|
+
return {
|
|
88
|
+
kind: "engine.usage",
|
|
89
|
+
reportId: input.reportId,
|
|
90
|
+
taskId: input.taskId,
|
|
91
|
+
agentId: input.agentId,
|
|
92
|
+
engine: input.engine,
|
|
93
|
+
credentialId: input.credentialId,
|
|
94
|
+
...(billing === undefined ? {} : { billing, revision: account!.revision }),
|
|
95
|
+
status: usage.status,
|
|
96
|
+
...(usage.resetsAt === undefined ? {} : { resetsAt: usage.resetsAt }),
|
|
97
|
+
...(usage.usingOverage === undefined
|
|
98
|
+
? {}
|
|
99
|
+
: { usingOverage: usage.usingOverage }),
|
|
100
|
+
windows: usage.windows,
|
|
101
|
+
source: usage.source,
|
|
102
|
+
observedAt: usage.observedAt,
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -5536,6 +5536,14 @@ function expectTaskAgents(value: unknown): TaskAgent[] {
|
|
|
5536
5536
|
// effort was previously dropped here even though the protocol + adapters
|
|
5537
5537
|
// carry it — fixed alongside skills (ADR-046).
|
|
5538
5538
|
if (typeof row.effort === "string") out.effort = row.effort;
|
|
5539
|
+
if (
|
|
5540
|
+
typeof row.accountId === "string" &&
|
|
5541
|
+
row.accountId.length > 0 &&
|
|
5542
|
+
row.accountId.length <= 128 &&
|
|
5543
|
+
/^[A-Za-z0-9_.:-]+$/.test(row.accountId)
|
|
5544
|
+
) {
|
|
5545
|
+
out.accountId = row.accountId;
|
|
5546
|
+
}
|
|
5539
5547
|
if (row.uaiProvider === "anthropic" || row.uaiProvider === "openai") {
|
|
5540
5548
|
out.uaiProvider = row.uaiProvider;
|
|
5541
5549
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -194,6 +194,14 @@ export interface EngineUsageFrame {
|
|
|
194
194
|
engine: "claude" | "codex" | "grok" | "cursor" | "opencode";
|
|
195
195
|
/** The `uai_credentials` id the agent's session runs on. */
|
|
196
196
|
credentialId: string;
|
|
197
|
+
/** How this account is paid for, when the leased variable's NAME tells the
|
|
198
|
+
* runner unambiguously. Derived only from that name, never from its value. */
|
|
199
|
+
billing?: "subscription" | "api-key";
|
|
200
|
+
/** The credential revision whose variable name gave `billing`: the one the
|
|
201
|
+
* agent's session runs on. Sent only with `billing`; an id, not a secret.
|
|
202
|
+
* The cloud records the hint only while this is still the account's active
|
|
203
|
+
* revision, so a task on an older lease cannot describe newer material. */
|
|
204
|
+
revision?: string;
|
|
197
205
|
/** `allowed`: working; `warning`: the engine says it is close to a limit;
|
|
198
206
|
* `limited`: cut off until `resetsAt` (or an unknown time). */
|
|
199
207
|
status: "allowed" | "warning" | "limited";
|
|
@@ -3121,6 +3129,8 @@ export interface TaskAgent {
|
|
|
3121
3129
|
kind: string;
|
|
3122
3130
|
model?: string;
|
|
3123
3131
|
effort?: string;
|
|
3132
|
+
/** ADR-157 addendum §7: preferred cloud-leased AI account. */
|
|
3133
|
+
accountId?: string;
|
|
3124
3134
|
defaultPrompt?: string;
|
|
3125
3135
|
initialPrompt?: string;
|
|
3126
3136
|
/** ADR-046 reference material resolved from the agent's persona + team.
|