@ccmsg/cli 0.1.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/LICENSE +21 -0
- package/README.md +23 -0
- package/package.json +32 -0
- package/src/cli.ts +1074 -0
- package/src/daemon/control.ts +88 -0
- package/src/daemon/index.ts +6 -0
- package/src/daemon/link.ts +93 -0
- package/src/daemon/log.ts +116 -0
- package/src/daemon/registry.ts +285 -0
- package/src/daemon/snapshot.ts +115 -0
- package/src/daemon/supervise.ts +446 -0
- package/src/dispatch/caller.ts +47 -0
- package/src/dispatch/dispatch.ts +128 -0
- package/src/dispatch/handler.ts +55 -0
- package/src/dispatch/identity.ts +22 -0
- package/src/dispatch/index.ts +5 -0
- package/src/dispatch/result.ts +58 -0
- package/src/files/containment.ts +263 -0
- package/src/files/files.ts +421 -0
- package/src/files/index.ts +14 -0
- package/src/files/sandbox.ts +0 -0
- package/src/greeting/hook.ts +48 -0
- package/src/greeting/index.ts +2 -0
- package/src/greeting/meta.ts +66 -0
- package/src/instance/config.ts +424 -0
- package/src/instance/handlers.ts +28 -0
- package/src/instance/identity.ts +44 -0
- package/src/instance/index.ts +8 -0
- package/src/instance/instance.ts +911 -0
- package/src/instance/lock.ts +108 -0
- package/src/instance/log.ts +30 -0
- package/src/instance/paths.ts +200 -0
- package/src/instance/socket.ts +62 -0
- package/src/kv/index.ts +2 -0
- package/src/kv/merge.ts +66 -0
- package/src/kv/store.ts +195 -0
- package/src/launcher/index.ts +4 -0
- package/src/launcher/launcher.ts +190 -0
- package/src/launcher/roots.ts +32 -0
- package/src/launcher/spawn.ts +81 -0
- package/src/launcher/tree.ts +80 -0
- package/src/mesh/index.ts +5 -0
- package/src/mesh/keys.ts +158 -0
- package/src/mesh/mesh.ts +1169 -0
- package/src/mesh/probe.ts +100 -0
- package/src/mesh/relay.ts +147 -0
- package/src/mesh/wire.ts +96 -0
- package/src/messaging/delivery.ts +375 -0
- package/src/messaging/direct.ts +433 -0
- package/src/messaging/handlers.ts +14 -0
- package/src/messaging/inbox.ts +191 -0
- package/src/messaging/index.ts +5 -0
- package/src/messaging/notify.ts +117 -0
- package/src/plugin/claude.ts +148 -0
- package/src/plugin/index.ts +13 -0
- package/src/plugin/install.ts +416 -0
- package/src/service/index.ts +1 -0
- package/src/service/service.ts +359 -0
- package/src/sessions/classify.ts +66 -0
- package/src/sessions/dump.ts +105 -0
- package/src/sessions/fork.ts +127 -0
- package/src/sessions/handlers.ts +158 -0
- package/src/sessions/harness.ts +167 -0
- package/src/sessions/index.ts +26 -0
- package/src/sessions/last-live.ts +111 -0
- package/src/sessions/processes.ts +413 -0
- package/src/sessions/registry.ts +785 -0
- package/src/sessions/search.ts +278 -0
- package/src/sessions/status.ts +209 -0
- package/src/sessions/terminals.ts +72 -0
- package/src/sessions/workspace.ts +140 -0
- package/src/topics/handlers.ts +42 -0
- package/src/topics/index.ts +2 -0
- package/src/topics/topics.ts +290 -0
- package/src/transcript/files.ts +201 -0
- package/src/transcript/fold.ts +833 -0
- package/src/transcript/index.ts +16 -0
- package/src/transcript/read.ts +82 -0
- package/src/transcript/tail.ts +195 -0
- package/src/transcript/transcripts.ts +162 -0
- package/src/translate/helper.ts +87 -0
- package/src/translate/index.ts +2 -0
- package/src/translate/translate.ts +127 -0
- package/src/transport/conn.ts +129 -0
- package/src/transport/dial.ts +65 -0
- package/src/transport/driver.ts +102 -0
- package/src/transport/entry.ts +39 -0
- package/src/transport/framing.ts +131 -0
- package/src/transport/index.ts +8 -0
- package/src/transport/listener.ts +39 -0
- package/src/transport/uds.ts +88 -0
- package/src/transport/ws.ts +170 -0
- package/src/upstream/events.ts +125 -0
- package/src/upstream/gateway.ts +275 -0
- package/src/upstream/index.ts +8 -0
- package/src/upstream/json.ts +81 -0
- package/src/upstream/requests.ts +234 -0
- package/src/upstream/stats.ts +99 -0
- package/src/upstream/status.ts +281 -0
- package/src/upstream/usage.ts +208 -0
- package/src/upstream/webhook.ts +141 -0
- package/src/version.ts +8 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type InstanceId,
|
|
3
|
+
llmCacheWindowEndAt,
|
|
4
|
+
type LlmRequestInfo,
|
|
5
|
+
type Sid,
|
|
6
|
+
type Timestamp,
|
|
7
|
+
} from "@ccmsg/protocol";
|
|
8
|
+
import { GATEWAY_LIVE_WINDOW_MS } from "../sessions/index.ts";
|
|
9
|
+
import type { TopicValue, UpstreamResource } from "../topics/index.ts";
|
|
10
|
+
import type { LlmRequestObservation } from "./events.ts";
|
|
11
|
+
|
|
12
|
+
export interface LlmRequestsDeps {
|
|
13
|
+
readonly self: InstanceId;
|
|
14
|
+
/** The one way a value reaches subscribers (§6.1). */
|
|
15
|
+
readonly publish: (topic: string, data: unknown) => void;
|
|
16
|
+
/** An event moved when a session was last seen running inference, which is
|
|
17
|
+
* an input of the sessions domain (§5.1) and not of this topic. */
|
|
18
|
+
readonly onActivity?: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Series remembered at once. The prune below already holds this near the
|
|
22
|
+
* number active in the last cache window; the cap is what bounds a gateway
|
|
23
|
+
* whose clock runs ahead, whose events would otherwise never expire. */
|
|
24
|
+
const MAX_SERIES = 500;
|
|
25
|
+
|
|
26
|
+
/** Prefixes whose session set is remembered. Sharing is a property of the
|
|
27
|
+
* prefix and stays worth knowing after that series' window closes, but not
|
|
28
|
+
* forever — the least recently seen go first. */
|
|
29
|
+
const MAX_PREFIXES = 2000;
|
|
30
|
+
|
|
31
|
+
/** One series' latest request, and when this session first used it. */
|
|
32
|
+
interface Series {
|
|
33
|
+
info: LlmRequestObservation;
|
|
34
|
+
/** Orders a session's series by when it started using them, which is the
|
|
35
|
+
* tiebreak when it has several the sharing rule does not disqualify. */
|
|
36
|
+
firstSeen: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What the gateway saw go upstream, per conversation series, and when each
|
|
40
|
+
* session was last seen running inference.
|
|
41
|
+
*
|
|
42
|
+
* Keyed on the session and its series rather than on the session alone: a
|
|
43
|
+
* session's subagents travel under its own id with a system prompt of their
|
|
44
|
+
* own, so their cache windows are genuinely separate. Folding them together
|
|
45
|
+
* would restart the session's countdown every time a subagent spoke.
|
|
46
|
+
*
|
|
47
|
+
* The frames carry the whole unexpired set rather than the series that just
|
|
48
|
+
* moved, which is the topic's `per_instance_whole` granularity: a frame is the
|
|
49
|
+
* whole of what this instance knows and replaces its share alone. It is what
|
|
50
|
+
* lets a client that starts listening mid-window draw the countdown that began
|
|
51
|
+
* before it was there. Nothing states an expiry: both sides compute it with the
|
|
52
|
+
* contract's own `llmCacheWindowEndAt`, so a window closes at the same instant
|
|
53
|
+
* here and on the screen. */
|
|
54
|
+
export class LlmRequests implements UpstreamResource {
|
|
55
|
+
readonly #series = new Map<string, Series>();
|
|
56
|
+
/** Prefix to the sessions it has been seen under, counted only as far as the
|
|
57
|
+
* two it takes to prove sharing. */
|
|
58
|
+
readonly #sidsByPrefix = new Map<string, Set<Sid>>();
|
|
59
|
+
/** When each session was last seen running inference: the newest of its
|
|
60
|
+
* requests and its answers. */
|
|
61
|
+
readonly #activeAt = new Map<Sid, Timestamp>();
|
|
62
|
+
#sequence = 0;
|
|
63
|
+
|
|
64
|
+
constructor(private readonly deps: LlmRequestsDeps) {}
|
|
65
|
+
|
|
66
|
+
/** Take one request the gateway forwarded.
|
|
67
|
+
*
|
|
68
|
+
* The newer of the two wins when a series already has one: events are
|
|
69
|
+
* near-ordered in practice, but a redelivery can put an older one after a
|
|
70
|
+
* newer, and a countdown must not walk backwards. */
|
|
71
|
+
record(info: LlmRequestObservation): void {
|
|
72
|
+
this.active(info.sid, info.received_at);
|
|
73
|
+
const key = seriesKey(info.sid, info.prefix);
|
|
74
|
+
const held = this.#series.get(key);
|
|
75
|
+
if (held !== undefined && held.info.received_at >= info.received_at) return;
|
|
76
|
+
this.notePrefix(info);
|
|
77
|
+
// Removed before it is put back so the re-insert moves the series to the
|
|
78
|
+
// end of the map's order, which is what makes the eviction below drop the
|
|
79
|
+
// one seen least recently. `firstSeen` survives that move.
|
|
80
|
+
this.#series.delete(key);
|
|
81
|
+
this.#series.set(key, { info, firstSeen: held?.firstSeen ?? ++this.#sequence });
|
|
82
|
+
while (this.#series.size > MAX_SERIES) {
|
|
83
|
+
const oldest = this.#series.keys().next();
|
|
84
|
+
if (oldest.done === true) break;
|
|
85
|
+
this.#series.delete(oldest.value);
|
|
86
|
+
}
|
|
87
|
+
this.publish();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Take one answer the gateway saw close. It moves nothing on this topic —
|
|
91
|
+
* the window belongs to the request that opened it — and only says the
|
|
92
|
+
* session was still running inference at that instant. */
|
|
93
|
+
note(sid: Sid, at: Timestamp): void {
|
|
94
|
+
if (this.active(sid, at)) this.deps.onActivity?.();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** When the gateway last saw inference for a session (§5.1). Undefined once
|
|
98
|
+
* that is old enough to say nothing about whether the session is alive. */
|
|
99
|
+
activeAt(sid: Sid, now: Timestamp = Date.now()): Timestamp | undefined {
|
|
100
|
+
const at = this.#activeAt.get(sid);
|
|
101
|
+
if (at === undefined) return undefined;
|
|
102
|
+
return now - at <= GATEWAY_LIVE_WINDOW_MS ? at : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every series whose cache window is still open, each told whether it is
|
|
106
|
+
* its session's main one. Prunes as it goes: a closed window is dropped
|
|
107
|
+
* rather than re-sent forever. */
|
|
108
|
+
entries(now: Timestamp = Date.now()): LlmRequestInfo[] {
|
|
109
|
+
const live: Series[] = [];
|
|
110
|
+
for (const [key, series] of this.#series) {
|
|
111
|
+
if (llmCacheWindowEndAt(series.info) <= now) {
|
|
112
|
+
this.#series.delete(key);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
live.push(series);
|
|
116
|
+
}
|
|
117
|
+
const main = this.#elect(live);
|
|
118
|
+
return live.map((series) => ({
|
|
119
|
+
...series.info,
|
|
120
|
+
instance: this.deps.self,
|
|
121
|
+
main: main.get(series.info.sid) === series,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- UpstreamResource (§6.3). There is nothing to start: the events are
|
|
126
|
+
// pushed to this instance whether or not anyone is listening, because the
|
|
127
|
+
// sessions domain reads the same arrivals for a value of its own.
|
|
128
|
+
|
|
129
|
+
start(): void {}
|
|
130
|
+
|
|
131
|
+
stop(): void {}
|
|
132
|
+
|
|
133
|
+
snapshot(): readonly TopicValue[] {
|
|
134
|
+
return [{ instance: this.deps.self, data: this.entries() }];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private publish(): void {
|
|
138
|
+
this.deps.publish("llm_requests", this.entries());
|
|
139
|
+
this.deps.onActivity?.();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Note the session was seen, and say whether that moved it forward. */
|
|
143
|
+
private active(sid: Sid, at: Timestamp): boolean {
|
|
144
|
+
const held = this.#activeAt.get(sid);
|
|
145
|
+
if (held !== undefined && held >= at) return false;
|
|
146
|
+
this.#activeAt.set(sid, at);
|
|
147
|
+
// Sessions the gateway has not seen for longer than the window go: what is
|
|
148
|
+
// left is what any of this can still say something about.
|
|
149
|
+
const floor = at - GATEWAY_LIVE_WINDOW_MS;
|
|
150
|
+
for (const [seen, when] of this.#activeAt) {
|
|
151
|
+
if (when < floor) this.#activeAt.delete(seen);
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private notePrefix(info: LlmRequestObservation): void {
|
|
157
|
+
const prefix = info.prefix;
|
|
158
|
+
if (prefix === undefined) return;
|
|
159
|
+
let sids = this.#sidsByPrefix.get(prefix);
|
|
160
|
+
if (sids === undefined) {
|
|
161
|
+
sids = new Set();
|
|
162
|
+
this.#sidsByPrefix.set(prefix, sids);
|
|
163
|
+
while (this.#sidsByPrefix.size > MAX_PREFIXES) {
|
|
164
|
+
const oldest = this.#sidsByPrefix.keys().next();
|
|
165
|
+
if (oldest.done === true) break;
|
|
166
|
+
this.#sidsByPrefix.delete(oldest.value);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (sids.size < 2) sids.add(info.sid);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Which series is each session's own: the three steps the contract states
|
|
173
|
+
* on `LlmRequestInfo.main`, in that order.
|
|
174
|
+
*
|
|
175
|
+
* Read from what is live right now rather than settled once, so an instance
|
|
176
|
+
* that started while only a subagent was talking corrects itself the moment
|
|
177
|
+
* that prefix appears under a second session. */
|
|
178
|
+
#elect(live: readonly Series[]): Map<Sid, Series> {
|
|
179
|
+
const stated = new Map<Sid, Series>();
|
|
180
|
+
const statedSids = new Set<Sid>();
|
|
181
|
+
for (const series of live) {
|
|
182
|
+
if (series.info.origin === undefined) continue;
|
|
183
|
+
statedSids.add(series.info.sid);
|
|
184
|
+
if (series.info.origin !== "main") continue;
|
|
185
|
+
// The newest rather than the first: a stated main series is legitimately
|
|
186
|
+
// replaced, since a compaction rewrites the system prompt.
|
|
187
|
+
const best = stated.get(series.info.sid);
|
|
188
|
+
if (best === undefined || series.info.received_at > best.info.received_at) {
|
|
189
|
+
stated.set(series.info.sid, series);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const elected = new Map<Sid, Series>();
|
|
194
|
+
for (const series of live) {
|
|
195
|
+
if (statedSids.has(series.info.sid)) continue;
|
|
196
|
+
if (this.shared(series.info.prefix)) continue;
|
|
197
|
+
const best = elected.get(series.info.sid);
|
|
198
|
+
if (best === undefined || series.firstSeen < best.firstSeen) {
|
|
199
|
+
elected.set(series.info.sid, series);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Step 3. Two sessions opened on the same directory of the same repository
|
|
203
|
+
// produce the same leading system block, so their own series share a prefix
|
|
204
|
+
// and disqualify each other; the separation being given up here has nothing
|
|
205
|
+
// left to separate.
|
|
206
|
+
const fallback = new Map<Sid, Series>();
|
|
207
|
+
for (const series of live) {
|
|
208
|
+
const sid = series.info.sid;
|
|
209
|
+
if (statedSids.has(sid) || elected.has(sid)) continue;
|
|
210
|
+
const best = fallback.get(sid);
|
|
211
|
+
if (best === undefined || series.info.received_at > best.info.received_at) {
|
|
212
|
+
fallback.set(sid, series);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const [sid, series] of fallback) elected.set(sid, series);
|
|
216
|
+
for (const [sid, series] of stated) elected.set(sid, series);
|
|
217
|
+
return elected;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private shared(prefix: string | undefined): boolean {
|
|
221
|
+
if (prefix === undefined) return false;
|
|
222
|
+
return (this.#sidsByPrefix.get(prefix)?.size ?? 0) > 1;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The key of one conversation series.
|
|
227
|
+
*
|
|
228
|
+
* The first half is measured rather than delimited, so there is no character
|
|
229
|
+
* that has to be impossible in a session id for two different pairs to stay
|
|
230
|
+
* apart. A session whose gateway reports no series has one unnamed series,
|
|
231
|
+
* which is what the absent half stands for. */
|
|
232
|
+
function seriesKey(sid: Sid, prefix: string | undefined): string {
|
|
233
|
+
return `${sid.length}:${sid}${prefix ?? ""}`;
|
|
234
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LlmStatsDay,
|
|
3
|
+
LlmStatsModelUsage,
|
|
4
|
+
LlmStatsReadArgs,
|
|
5
|
+
LlmStatsReadResult,
|
|
6
|
+
} from "@ccmsg/protocol";
|
|
7
|
+
import { OpError } from "../dispatch/index.ts";
|
|
8
|
+
import { fetchJson, objectOf, optionalInstant, optionalInteger, optionalNumber } from "./json.ts";
|
|
9
|
+
import { withQuery } from "./usage.ts";
|
|
10
|
+
|
|
11
|
+
/** A year of daily spend across every credential and model is a few hundred
|
|
12
|
+
* kilobytes; this leaves room for a busier host without letting a misconfigured
|
|
13
|
+
* address stream this instance out of memory. */
|
|
14
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
/** Wider than the quota read's: this document is assembled over a range the
|
|
17
|
+
* caller chose, so a year is legitimately slower than a snapshot. */
|
|
18
|
+
const TIMEOUT_MS = 30_000;
|
|
19
|
+
|
|
20
|
+
export interface StatsDeps {
|
|
21
|
+
/** Where the gateway answers, already joined to its stats path. */
|
|
22
|
+
readonly url: string;
|
|
23
|
+
readonly fetch?: typeof fetch;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Ask the gateway what the host's credentials have cost, by day. */
|
|
27
|
+
export async function readStats(
|
|
28
|
+
deps: StatsDeps,
|
|
29
|
+
args: LlmStatsReadArgs,
|
|
30
|
+
): Promise<LlmStatsReadResult> {
|
|
31
|
+
// A window the caller did not name is the gateway's own to choose.
|
|
32
|
+
const url = args.days === undefined ? deps.url : withQuery(deps.url, "days", String(args.days));
|
|
33
|
+
let document: unknown;
|
|
34
|
+
try {
|
|
35
|
+
document = await fetchJson(url, {
|
|
36
|
+
timeoutMs: TIMEOUT_MS,
|
|
37
|
+
maxBytes: MAX_BYTES,
|
|
38
|
+
...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
|
|
39
|
+
});
|
|
40
|
+
} catch (cause) {
|
|
41
|
+
throw new OpError("internal_error", `the gateway's spend could not be read: ${String(cause)}`);
|
|
42
|
+
}
|
|
43
|
+
const stats = statsOf(document);
|
|
44
|
+
if (stats === undefined) {
|
|
45
|
+
throw new OpError("internal_error", "the gateway's spend could not be understood");
|
|
46
|
+
}
|
|
47
|
+
return stats;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read the gateway's document as this contract's answer (§3.5). The dates are
|
|
51
|
+
* the gateway's own keys and are not reinterpreted: a day here means whatever
|
|
52
|
+
* it means there. */
|
|
53
|
+
export function statsOf(value: unknown): LlmStatsReadResult | undefined {
|
|
54
|
+
const raw = objectOf(value);
|
|
55
|
+
const rawDays = objectOf(raw?.["days"]);
|
|
56
|
+
if (raw === undefined || rawDays === undefined) return undefined;
|
|
57
|
+
const days: Record<string, LlmStatsDay> = {};
|
|
58
|
+
for (const [date, entry] of Object.entries(rawDays)) {
|
|
59
|
+
const day = dayOf(entry);
|
|
60
|
+
if (day !== undefined) days[date] = day;
|
|
61
|
+
}
|
|
62
|
+
return { ...optionalInstant("generated_at", raw["generated_at"]), days };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A day whose credentials cannot be read still renders as a day with its
|
|
66
|
+
* total, which says more than a hole in the series would. */
|
|
67
|
+
function dayOf(value: unknown): LlmStatsDay | undefined {
|
|
68
|
+
const raw = objectOf(value);
|
|
69
|
+
if (raw === undefined) return undefined;
|
|
70
|
+
const credentials: Record<string, Record<string, LlmStatsModelUsage>> = {};
|
|
71
|
+
const rawCredentials = objectOf(raw["credentials"]);
|
|
72
|
+
for (const [name, rawModels] of Object.entries(rawCredentials ?? {})) {
|
|
73
|
+
const models = objectOf(rawModels);
|
|
74
|
+
if (models === undefined) continue;
|
|
75
|
+
const byModel: Record<string, LlmStatsModelUsage> = {};
|
|
76
|
+
for (const [model, entry] of Object.entries(models)) {
|
|
77
|
+
const usage = modelUsageOf(entry);
|
|
78
|
+
if (usage !== undefined) byModel[model] = usage;
|
|
79
|
+
}
|
|
80
|
+
credentials[name] = byModel;
|
|
81
|
+
}
|
|
82
|
+
return { credentials, ...optionalNumber("total_usd", raw["total_usd"]) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Every counter is optional and absent when it was not reported: a counter a
|
|
86
|
+
* display sums has to be a number or nothing, never a zero something else
|
|
87
|
+
* would then add up. */
|
|
88
|
+
function modelUsageOf(value: unknown): LlmStatsModelUsage | undefined {
|
|
89
|
+
const raw = objectOf(value);
|
|
90
|
+
if (raw === undefined) return undefined;
|
|
91
|
+
return {
|
|
92
|
+
...optionalInteger("requests", raw["requests"]),
|
|
93
|
+
...optionalInteger("input_tokens", raw["input_tokens"]),
|
|
94
|
+
...optionalInteger("output_tokens", raw["output_tokens"]),
|
|
95
|
+
...optionalInteger("cache_creation_input_tokens", raw["cache_creation_input_tokens"]),
|
|
96
|
+
...optionalInteger("cache_read_input_tokens", raw["cache_read_input_tokens"]),
|
|
97
|
+
...optionalNumber("usd", raw["usd"]),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InstanceId,
|
|
3
|
+
LlmStatusComponent,
|
|
4
|
+
LlmStatusIncident,
|
|
5
|
+
LlmStatusObserved,
|
|
6
|
+
LlmStatusObservedState,
|
|
7
|
+
LlmStatusOfficial,
|
|
8
|
+
LlmStatusOfficialState,
|
|
9
|
+
LlmStatusReport,
|
|
10
|
+
LlmStatusService,
|
|
11
|
+
LlmStatusSeverity,
|
|
12
|
+
} from "@ccmsg/protocol";
|
|
13
|
+
import type { TopicValue, UpstreamResource } from "../topics/index.ts";
|
|
14
|
+
import {
|
|
15
|
+
fetchJson,
|
|
16
|
+
objectOf,
|
|
17
|
+
oneOf,
|
|
18
|
+
optionalInstant,
|
|
19
|
+
optionalInteger,
|
|
20
|
+
optionalText,
|
|
21
|
+
} from "./json.ts";
|
|
22
|
+
|
|
23
|
+
/** How long the read is given. The gateway answers from a snapshot it keeps,
|
|
24
|
+
* so a read that takes longer than this is one that is not coming. */
|
|
25
|
+
const TIMEOUT_MS = 10_000;
|
|
26
|
+
|
|
27
|
+
/** Cap on the document. A report names the services behind one gateway; past
|
|
28
|
+
* this it is not one, and the whole of it is held in memory to be parsed. */
|
|
29
|
+
const MAX_BYTES = 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
/** How long the trouble a request event reports is left to settle before the
|
|
32
|
+
* report is re-read.
|
|
33
|
+
*
|
|
34
|
+
* A single upstream failure arrives as a burst — several routes are tried and
|
|
35
|
+
* each refusal is its own event — and the gateway is refreshing its own
|
|
36
|
+
* sources on the same trigger. Reading once after the burst gets one answer
|
|
37
|
+
* that has had a moment to become true, instead of one read per refusal. */
|
|
38
|
+
const TROUBLE_SETTLE_MS = 5_000;
|
|
39
|
+
|
|
40
|
+
/** The HTTP statuses that say the trouble is upstream's rather than this
|
|
41
|
+
* request's. Quota and credential refusals are a different report's subject
|
|
42
|
+
* (they reach a client as that credential's error), and a plain 5xx can be
|
|
43
|
+
* synthesised anywhere along the way — the gateway itself only treats an
|
|
44
|
+
* overload as a service signal, and neither does this. */
|
|
45
|
+
function isUpstreamTrouble(status: number | undefined): boolean {
|
|
46
|
+
return status === 529;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LlmStatusDeps {
|
|
50
|
+
readonly self: InstanceId;
|
|
51
|
+
/** Where the gateway answers, already joined to its status path. */
|
|
52
|
+
readonly url: string;
|
|
53
|
+
readonly publish: (topic: string, data: unknown) => void;
|
|
54
|
+
readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
|
|
55
|
+
/** Replaces the fetch and the delay in tests. */
|
|
56
|
+
readonly fetch?: typeof fetch;
|
|
57
|
+
readonly settleMs?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The gateway's report on the services behind it.
|
|
61
|
+
*
|
|
62
|
+
* `per_instance_whole` (§6.2): a frame replaces what this instance last said
|
|
63
|
+
* and leaves other instances' reports alone, because the report is one document
|
|
64
|
+
* the gateway behind this instance assembles and half of it means nothing on
|
|
65
|
+
* its own.
|
|
66
|
+
*
|
|
67
|
+
* It is read at two moments and no others (M3): when someone starts listening,
|
|
68
|
+
* and once after a request event says an upstream refused. There is no poll —
|
|
69
|
+
* a report only changes when something upstream did, and a request event is
|
|
70
|
+
* this instance being told exactly that. */
|
|
71
|
+
export class LlmStatus implements UpstreamResource {
|
|
72
|
+
#report: LlmStatusReport | undefined;
|
|
73
|
+
#reading: Promise<void> | undefined;
|
|
74
|
+
#settling: ReturnType<typeof setTimeout> | undefined;
|
|
75
|
+
#listening = false;
|
|
76
|
+
|
|
77
|
+
constructor(private readonly deps: LlmStatusDeps) {}
|
|
78
|
+
|
|
79
|
+
/** A request the gateway forwarded says an upstream refused it. The read is
|
|
80
|
+
* deferred, and a second refusal inside the same window joins the first
|
|
81
|
+
* rather than starting its own. */
|
|
82
|
+
noteRequestStatus(status: number | undefined): void {
|
|
83
|
+
if (!isUpstreamTrouble(status) || !this.#listening || this.#settling !== undefined) return;
|
|
84
|
+
this.#settling = setTimeout(() => {
|
|
85
|
+
this.#settling = undefined;
|
|
86
|
+
void this.read();
|
|
87
|
+
}, this.deps.settleMs ?? TROUBLE_SETTLE_MS);
|
|
88
|
+
// The instance must be able to leave while this is pending (§8.5).
|
|
89
|
+
this.#settling.unref?.();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Read the report and state it. Two callers at once share one read: the
|
|
93
|
+
* answer is the same document, and asking the gateway twice for it is what
|
|
94
|
+
* a burst of refusals would otherwise do. */
|
|
95
|
+
async read(): Promise<void> {
|
|
96
|
+
this.#reading ??= this.#read().finally(() => {
|
|
97
|
+
this.#reading = undefined;
|
|
98
|
+
});
|
|
99
|
+
await this.#reading;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- UpstreamResource (§6.3)
|
|
103
|
+
|
|
104
|
+
start(): void {
|
|
105
|
+
this.#listening = true;
|
|
106
|
+
void this.read();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
stop(): void {
|
|
110
|
+
this.#listening = false;
|
|
111
|
+
if (this.#settling !== undefined) clearTimeout(this.#settling);
|
|
112
|
+
this.#settling = undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What is held, if anything has been read. A subscriber that arrives before
|
|
116
|
+
* the first read gets nothing and then the report, rather than an empty
|
|
117
|
+
* report it would show as "every service unknown". */
|
|
118
|
+
snapshot(): readonly TopicValue[] {
|
|
119
|
+
if (this.#report === undefined) return [];
|
|
120
|
+
return [{ instance: this.deps.self, data: this.#report }];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async #read(): Promise<void> {
|
|
124
|
+
let document: unknown;
|
|
125
|
+
try {
|
|
126
|
+
document = await fetchJson(this.deps.url, {
|
|
127
|
+
timeoutMs: TIMEOUT_MS,
|
|
128
|
+
maxBytes: MAX_BYTES,
|
|
129
|
+
...(this.deps.fetch === undefined ? {} : { fetch: this.deps.fetch }),
|
|
130
|
+
});
|
|
131
|
+
} catch (cause) {
|
|
132
|
+
// The last good report is kept: a read that failed says nothing about
|
|
133
|
+
// the services, and replacing what is known with nothing would blank the
|
|
134
|
+
// display over this instance's own trouble.
|
|
135
|
+
this.deps.log?.("could not read the gateway's status", { error: String(cause) });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const report = reportOf(document);
|
|
139
|
+
if (report === undefined) {
|
|
140
|
+
this.deps.log?.("the gateway's status could not be understood");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
this.#report = report;
|
|
144
|
+
this.deps.publish("llm_status", report);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const SEVERITIES: readonly LlmStatusSeverity[] = ["ok", "warning", "critical", "unknown"];
|
|
149
|
+
const OFFICIAL_STATES: readonly LlmStatusOfficialState[] = [
|
|
150
|
+
"operational",
|
|
151
|
+
"degraded",
|
|
152
|
+
"partial_outage",
|
|
153
|
+
"major_outage",
|
|
154
|
+
"maintenance",
|
|
155
|
+
"unknown",
|
|
156
|
+
];
|
|
157
|
+
const OBSERVED_STATES: readonly LlmStatusObservedState[] = ["reachable", "failing", "unknown"];
|
|
158
|
+
|
|
159
|
+
/** Read the gateway's document as this contract's report (§3.5).
|
|
160
|
+
*
|
|
161
|
+
* The gateway already answers in Unix ms under these names, so nothing is
|
|
162
|
+
* converted — but nothing is passed through unread either: every field is
|
|
163
|
+
* taken only at the type the contract states, and each closed vocabulary is
|
|
164
|
+
* checked against its own set. A word outside one arrives as `unknown` rather
|
|
165
|
+
* than as itself, which is what keeps a future vocabulary from reaching a
|
|
166
|
+
* screen as something nothing can draw. The verdict itself is never
|
|
167
|
+
* recomputed: the gateway knows which of its two signals outweighs the other,
|
|
168
|
+
* and a second opinion here would disagree with every other reader of the same
|
|
169
|
+
* report. */
|
|
170
|
+
export function reportOf(value: unknown): LlmStatusReport | undefined {
|
|
171
|
+
const raw = objectOf(value);
|
|
172
|
+
if (raw === undefined) return undefined;
|
|
173
|
+
const overall = objectOf(raw["overall"]);
|
|
174
|
+
const services = raw["services"];
|
|
175
|
+
if (overall === undefined || !Array.isArray(services)) return undefined;
|
|
176
|
+
return {
|
|
177
|
+
...optionalInteger("schema_version", raw["schema_version"]),
|
|
178
|
+
...optionalInstant("generated_at", raw["generated_at"]),
|
|
179
|
+
overall: {
|
|
180
|
+
severity: oneOf(SEVERITIES, overall["severity"], "unknown"),
|
|
181
|
+
service_counts: countsOf(overall["service_counts"]),
|
|
182
|
+
},
|
|
183
|
+
services: services.flatMap((service) => serviceOf(service) ?? []),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function serviceOf(value: unknown): LlmStatusService | undefined {
|
|
188
|
+
const raw = objectOf(value);
|
|
189
|
+
if (raw === undefined) return undefined;
|
|
190
|
+
const id = raw["id"];
|
|
191
|
+
const name = raw["name"];
|
|
192
|
+
if (typeof id !== "string" || typeof name !== "string") return undefined;
|
|
193
|
+
const routes = raw["routes"];
|
|
194
|
+
const official = officialOf(raw["official"]);
|
|
195
|
+
const observed = observedOf(raw["observed"]);
|
|
196
|
+
return {
|
|
197
|
+
id,
|
|
198
|
+
name,
|
|
199
|
+
severity: oneOf(SEVERITIES, raw["severity"], "unknown"),
|
|
200
|
+
routes: Array.isArray(routes) ? routes.filter((route) => typeof route === "string") : [],
|
|
201
|
+
...(official === undefined ? {} : { official }),
|
|
202
|
+
...(observed === undefined ? {} : { observed }),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function officialOf(value: unknown): LlmStatusOfficial | undefined {
|
|
207
|
+
const raw = objectOf(value);
|
|
208
|
+
if (raw === undefined) return undefined;
|
|
209
|
+
const components = raw["components"];
|
|
210
|
+
const incidents = raw["incidents"];
|
|
211
|
+
return {
|
|
212
|
+
state: oneOf(OFFICIAL_STATES, raw["state"], "unknown"),
|
|
213
|
+
...optionalText("source", raw["source"]),
|
|
214
|
+
...optionalText("source_url", raw["source_url"]),
|
|
215
|
+
...optionalInstant("observed_at", raw["observed_at"]),
|
|
216
|
+
...(typeof raw["stale"] === "boolean" ? { stale: raw["stale"] } : {}),
|
|
217
|
+
components: Array.isArray(components) ? components.flatMap((c) => componentOf(c) ?? []) : [],
|
|
218
|
+
incidents: Array.isArray(incidents) ? incidents.flatMap((i) => incidentOf(i) ?? []) : [],
|
|
219
|
+
...optionalText("error", raw["error"]),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function componentOf(value: unknown): LlmStatusComponent | undefined {
|
|
224
|
+
const raw = objectOf(value);
|
|
225
|
+
if (raw === undefined || typeof raw["name"] !== "string") return undefined;
|
|
226
|
+
return {
|
|
227
|
+
...optionalText("id", raw["id"]),
|
|
228
|
+
name: raw["name"],
|
|
229
|
+
state: oneOf(OFFICIAL_STATES, raw["state"], "unknown"),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** One incident. Everything but its title is optional — a line with a title
|
|
234
|
+
* alone is still worth showing — and all of it is the provider's own prose,
|
|
235
|
+
* carried as text for a display that must never read it as markup. */
|
|
236
|
+
function incidentOf(value: unknown): LlmStatusIncident | undefined {
|
|
237
|
+
const raw = objectOf(value);
|
|
238
|
+
if (raw === undefined || typeof raw["name"] !== "string") return undefined;
|
|
239
|
+
return {
|
|
240
|
+
...optionalText("id", raw["id"]),
|
|
241
|
+
name: raw["name"],
|
|
242
|
+
...optionalText("state", raw["state"]),
|
|
243
|
+
...optionalText("impact", raw["impact"]),
|
|
244
|
+
...optionalInstant("created_at", raw["created_at"]),
|
|
245
|
+
...optionalInstant("updated_at", raw["updated_at"]),
|
|
246
|
+
...optionalText("url", raw["url"]),
|
|
247
|
+
...optionalText("latest_update", raw["latest_update"]),
|
|
248
|
+
...optionalText("scope", raw["scope"]),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function observedOf(value: unknown): LlmStatusObserved | undefined {
|
|
253
|
+
const raw = objectOf(value);
|
|
254
|
+
if (raw === undefined) return undefined;
|
|
255
|
+
const failure = objectOf(raw["last_failure"]);
|
|
256
|
+
return {
|
|
257
|
+
state: oneOf(OBSERVED_STATES, raw["state"], "unknown"),
|
|
258
|
+
...optionalInstant("observed_at", raw["observed_at"]),
|
|
259
|
+
...optionalInstant("expires_at", raw["expires_at"]),
|
|
260
|
+
...optionalInstant("last_success_at", raw["last_success_at"]),
|
|
261
|
+
...(failure === undefined
|
|
262
|
+
? {}
|
|
263
|
+
: {
|
|
264
|
+
last_failure: {
|
|
265
|
+
...optionalInstant("at", failure["at"]),
|
|
266
|
+
...optionalText("kind", failure["kind"]),
|
|
267
|
+
...optionalInteger("status", failure["status"]),
|
|
268
|
+
},
|
|
269
|
+
}),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function countsOf(value: unknown): Record<string, number> {
|
|
274
|
+
const raw = objectOf(value);
|
|
275
|
+
if (raw === undefined) return {};
|
|
276
|
+
const counts: Record<string, number> = {};
|
|
277
|
+
for (const [name, count] of Object.entries(raw)) {
|
|
278
|
+
if (typeof count === "number" && Number.isInteger(count) && count >= 0) counts[name] = count;
|
|
279
|
+
}
|
|
280
|
+
return counts;
|
|
281
|
+
}
|