@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,170 @@
|
|
|
1
|
+
import { MAX_FRAME_BYTES } from "@ccmsg/protocol";
|
|
2
|
+
import { BaseConn, type Conn, type ConnRegistry } from "./conn.ts";
|
|
3
|
+
import { createDriver, type FrameHandler } from "./driver.ts";
|
|
4
|
+
import { LineReader, WriteQueue } from "./framing.ts";
|
|
5
|
+
import { type EntryPolicy, OPEN } from "./entry.ts";
|
|
6
|
+
import type { Listener } from "./listener.ts";
|
|
7
|
+
|
|
8
|
+
/** What the upgrade hands the socket: whether it was let in as a peer. */
|
|
9
|
+
interface UpgradeData {
|
|
10
|
+
readonly mesh: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface WsState {
|
|
14
|
+
conn: BaseConn;
|
|
15
|
+
reader: LineReader;
|
|
16
|
+
queue: WriteQueue<string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WsOptions {
|
|
20
|
+
readonly hostname?: string;
|
|
21
|
+
/** 0 asks the kernel for a free port; the bound one is on `address`. */
|
|
22
|
+
readonly port: number;
|
|
23
|
+
readonly path?: string;
|
|
24
|
+
readonly conns: ConnRegistry;
|
|
25
|
+
readonly handle: FrameHandler;
|
|
26
|
+
readonly entry?: EntryPolicy;
|
|
27
|
+
/** `mesh` is set when the handshake was let in as a peer rather than on the
|
|
28
|
+
* entry token, so whoever holds the connection can keep it to the one
|
|
29
|
+
* exchange that can prove what it is. */
|
|
30
|
+
readonly onConn?: (conn: Conn, info: { readonly mesh: boolean }) => void;
|
|
31
|
+
/** An HTTP request that is not the upgrade, answered by whoever wants it.
|
|
32
|
+
*
|
|
33
|
+
* It shares this listener rather than opening a second one: a producer that
|
|
34
|
+
* posts to this instance reaches it at the address it already has, and the
|
|
35
|
+
* entry check of §3.1 runs before this is asked, so a route cannot be
|
|
36
|
+
* reached by anyone the WebSocket could not be. Answering `undefined` leaves
|
|
37
|
+
* the request to the upgrade, which refuses it. */
|
|
38
|
+
readonly route?: (request: Request) => Promise<Response | undefined>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Accept the webui, and later mesh peers, over WebSocket.
|
|
42
|
+
*
|
|
43
|
+
* A WS message is a message, not a stream, but it carries the same
|
|
44
|
+
* newline-delimited framing as the unix socket: one line is one frame, whether
|
|
45
|
+
* a message holds one line or several. Backpressure differs from UDS — a send
|
|
46
|
+
* is either buffered whole by Bun or dropped whole — and the queue absorbs
|
|
47
|
+
* that difference here (§3.1). */
|
|
48
|
+
export function serveWs(options: WsOptions): Listener {
|
|
49
|
+
const path = options.path ?? "/ws";
|
|
50
|
+
const entry = options.entry ?? OPEN;
|
|
51
|
+
// The per-connection state is made in `open`, where the socket to write to
|
|
52
|
+
// exists, so the upgrade carries nothing and the socket keeps no data of its
|
|
53
|
+
// own.
|
|
54
|
+
const states = new WeakMap<object, WsState>();
|
|
55
|
+
const server = Bun.serve<UpgradeData, never>({
|
|
56
|
+
hostname: options.hostname ?? "127.0.0.1",
|
|
57
|
+
port: options.port,
|
|
58
|
+
async fetch(request, srv) {
|
|
59
|
+
if (entry.allowRequest?.(request, srv.requestIP(request)?.address) === false) {
|
|
60
|
+
return new Response("Forbidden", { status: 403 });
|
|
61
|
+
}
|
|
62
|
+
const routed = await options.route?.(request);
|
|
63
|
+
if (routed !== undefined) return routed;
|
|
64
|
+
if (!entryPath(new URL(request.url).pathname, path)) {
|
|
65
|
+
return new Response("Not Found", { status: 404 });
|
|
66
|
+
}
|
|
67
|
+
// The handshake's own check, asked after the routes so a route carrying
|
|
68
|
+
// its own secret is not also asked for the entry token.
|
|
69
|
+
const decision = entry.allowUpgrade?.(request) ?? { ok: true as const };
|
|
70
|
+
if (!decision.ok) return new Response(decision.reason, { status: 401 });
|
|
71
|
+
const selected = decision.protocol;
|
|
72
|
+
if (
|
|
73
|
+
srv.upgrade(request, {
|
|
74
|
+
data: { mesh: decision.mesh === true },
|
|
75
|
+
...(selected === undefined
|
|
76
|
+
? {}
|
|
77
|
+
: { headers: { "sec-websocket-protocol": selected } satisfies Record<string, string> }),
|
|
78
|
+
})
|
|
79
|
+
) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return new Response("Expected a WebSocket upgrade", { status: 426 });
|
|
83
|
+
},
|
|
84
|
+
websocket: {
|
|
85
|
+
// The line limit is enforced by the framing both transports share, which
|
|
86
|
+
// answers `bad_request` and keeps the connection. This cap only bounds
|
|
87
|
+
// one message's memory, so it sits above the line limit — a message at
|
|
88
|
+
// or under it always reaches the framing and gets that answer.
|
|
89
|
+
maxPayloadLength: MAX_FRAME_BYTES + 64 * 1024,
|
|
90
|
+
open(ws) {
|
|
91
|
+
const queue = new WriteQueue<string>({
|
|
92
|
+
encode: (line) => line,
|
|
93
|
+
// 0 means the message was dropped; anything else means it was sent
|
|
94
|
+
// or is buffered by Bun and will be, so re-queuing it would send it
|
|
95
|
+
// twice.
|
|
96
|
+
write: (chunk) => (ws.send(chunk) === 0 ? chunk : undefined),
|
|
97
|
+
});
|
|
98
|
+
const conn = new BaseConn(options.conns.nextId(), {
|
|
99
|
+
send: (line) => {
|
|
100
|
+
queue.push(line);
|
|
101
|
+
},
|
|
102
|
+
close: (code, reason) => {
|
|
103
|
+
if (code === undefined) ws.close();
|
|
104
|
+
else ws.close(code, reason);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const driver = createDriver(conn, options.handle);
|
|
108
|
+
states.set(ws, { conn, queue, reader: new LineReader(driver) });
|
|
109
|
+
options.conns.add(conn);
|
|
110
|
+
const data = ws.data as UpgradeData | undefined;
|
|
111
|
+
options.onConn?.(conn, { mesh: data?.mesh === true });
|
|
112
|
+
},
|
|
113
|
+
message(ws, message) {
|
|
114
|
+
const state = states.get(ws);
|
|
115
|
+
if (state === undefined) return;
|
|
116
|
+
const bytes = typeof message === "string" ? new TextEncoder().encode(message) : message;
|
|
117
|
+
state.reader.push(bytes);
|
|
118
|
+
// A message need not end in a newline, and one message is one frame on
|
|
119
|
+
// this transport, so the last line is complete even without it.
|
|
120
|
+
state.reader.push(NEWLINE);
|
|
121
|
+
},
|
|
122
|
+
drain(ws) {
|
|
123
|
+
states.get(ws)?.queue.drain();
|
|
124
|
+
},
|
|
125
|
+
close(ws) {
|
|
126
|
+
const state = states.get(ws);
|
|
127
|
+
if (state === undefined) return;
|
|
128
|
+
options.conns.remove(state.conn);
|
|
129
|
+
state.conn.closed();
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
kind: "ws",
|
|
136
|
+
address: `${server.hostname}:${server.port}`,
|
|
137
|
+
async close() {
|
|
138
|
+
// Bounded rather than simply awaited. Measured against Bun 1.3.13: once
|
|
139
|
+
// this server has closed a WebSocket itself — which the mesh does, to
|
|
140
|
+
// drop the loser of a glare, a link gone silent, or a peer speaking out
|
|
141
|
+
// of turn — `stop` never settles, while the address is in fact given up
|
|
142
|
+
// within a millisecond and can be bound again. Waiting on the promise
|
|
143
|
+
// would hang the stop order at its last step for a listener that is
|
|
144
|
+
// already down, so the wait is capped and the address is what is trusted.
|
|
145
|
+
await Promise.race([server.stop(true), Bun.sleep(STOP_DEADLINE_MS)]);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Whether a request's path is this listener's entry.
|
|
151
|
+
*
|
|
152
|
+
* Matched at the end rather than whole, so a proxy that puts the instance under
|
|
153
|
+
* a prefix of its own passes the request through untouched and an alias URL
|
|
154
|
+
* reaches the same door (DR-0001 §2.7). The boundary before it has to be a
|
|
155
|
+
* separator, or `/notws` would answer for `/ws`. The mesh's own routes are not
|
|
156
|
+
* matched this way: they stay under the configured endpoint, which is what
|
|
157
|
+
* keeps two instances on one origin from answering for each other's keys.
|
|
158
|
+
*
|
|
159
|
+
* The prefix is not read: which instance a proxy meant is settled by which
|
|
160
|
+
* address it forwarded to, and a person's connection names itself by
|
|
161
|
+
* authenticating rather than by the path it arrived on. */
|
|
162
|
+
export function entryPath(pathname: string, path: string): boolean {
|
|
163
|
+
return pathname === path || pathname.endsWith(`/${path.replace(/^\//, "")}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** How long the stop above waits before trusting the address over the promise.
|
|
167
|
+
* Two orders of magnitude above the millisecond the release was measured at. */
|
|
168
|
+
const STOP_DEADLINE_MS = 250;
|
|
169
|
+
|
|
170
|
+
const NEWLINE = new Uint8Array([0x0a]);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { LlmRequestInfo, Sid, Timestamp } from "@ccmsg/protocol";
|
|
2
|
+
|
|
3
|
+
/** One request the gateway forwarded, in this contract's spelling, before the
|
|
4
|
+
* instance decides whether its series is the session's main one.
|
|
5
|
+
*
|
|
6
|
+
* `main` is a verdict about a session's several series, so it needs the other
|
|
7
|
+
* series to be made and cannot be read off one event (§3.5 renames, it does not
|
|
8
|
+
* derive). `instance` is stamped by whoever publishes, since an event says
|
|
9
|
+
* nothing about which instance received it. */
|
|
10
|
+
export type LlmRequestObservation = Omit<LlmRequestInfo, "main" | "instance">;
|
|
11
|
+
|
|
12
|
+
/** An answer the gateway saw close. Only the two fields the sessions domain
|
|
13
|
+
* reads: it says inference for that session has stopped running, and when. */
|
|
14
|
+
export interface LlmResponseObservation {
|
|
15
|
+
readonly sid: Sid;
|
|
16
|
+
readonly at: Timestamp;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One item of a posted batch, as this instance reads it.
|
|
20
|
+
*
|
|
21
|
+
* `ignored` is a kind the gateway sends that nothing here reads — kept apart
|
|
22
|
+
* from `undefined`, which is an item that could not be understood at all. The
|
|
23
|
+
* difference is the whole value of the log line: a batch of ignorable items is
|
|
24
|
+
* the gateway working, a batch of unreadable ones is a schema that moved. */
|
|
25
|
+
export type GatewayItem =
|
|
26
|
+
| { readonly kind: "request"; readonly info: LlmRequestObservation }
|
|
27
|
+
| { readonly kind: "response"; readonly info: LlmResponseObservation }
|
|
28
|
+
| { readonly kind: "ignored" };
|
|
29
|
+
|
|
30
|
+
/** Kinds the gateway posts beside the two above: a keepalive it wants replayed
|
|
31
|
+
* into a session, and its keepalive strategy being held off for one. Neither
|
|
32
|
+
* is read here. They are named rather than reached as "not a request", so a
|
|
33
|
+
* kind the gateway grows still arrives as unreadable and shows up in the log. */
|
|
34
|
+
const IGNORED = new Set(["cache_keepalive", "keepalive_paused"]);
|
|
35
|
+
|
|
36
|
+
/** The fields whose name is the same on both sides, and whose value is already
|
|
37
|
+
* this contract's unit — a count of seconds, or an instant in Unix ms. */
|
|
38
|
+
const SAME_NAME_NUMBERS = [
|
|
39
|
+
"cache_ttl_secs",
|
|
40
|
+
"cache_expires_at",
|
|
41
|
+
"cache_count",
|
|
42
|
+
"next_keepalive_at",
|
|
43
|
+
"cache_until_count",
|
|
44
|
+
"cache_breakeven_count",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
/** The instants the gateway names without the suffix this contract requires of
|
|
48
|
+
* every field that is a point in time. Renamed here, at the boundary, so
|
|
49
|
+
* nothing downstream sees the gateway's spelling (§3.5). */
|
|
50
|
+
const RENAMED_INSTANTS = [
|
|
51
|
+
["cache_since", "cache_since_at"],
|
|
52
|
+
["cache_until", "cache_until_at"],
|
|
53
|
+
["cache_breakeven_until", "cache_breakeven_until_at"],
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
const SAME_NAME_STRINGS = ["keepalive", "ns", "model", "credential", "origin"] as const;
|
|
57
|
+
|
|
58
|
+
/** Read one posted item.
|
|
59
|
+
*
|
|
60
|
+
* Nothing here throws: one item a batch could not be understood must not cost
|
|
61
|
+
* the items beside it, so an unusable one is `undefined` and the caller drops
|
|
62
|
+
* it. Every field is taken only when it is the type this contract states —
|
|
63
|
+
* which is also what keeps a value the gateway might one day send in another
|
|
64
|
+
* shape (an ISO instant, a string status) from travelling as itself. */
|
|
65
|
+
export function parseGatewayItem(value: unknown): GatewayItem | undefined {
|
|
66
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
67
|
+
const raw = value as Record<string, unknown>;
|
|
68
|
+
const kind = raw["type"];
|
|
69
|
+
if (typeof kind === "string" && IGNORED.has(kind)) return { kind: "ignored" };
|
|
70
|
+
// A client that named no session gives an event with no row to put it on.
|
|
71
|
+
// The gateway states the field as null rather than leaving it out, and this
|
|
72
|
+
// is the ordinary case of a call made by something other than a session — so
|
|
73
|
+
// it is passed over rather than counted as an event that could not be read.
|
|
74
|
+
if (raw["session_id"] === null) return { kind: "ignored" };
|
|
75
|
+
if (kind === "response") {
|
|
76
|
+
const info = responseOf(raw);
|
|
77
|
+
return info === undefined ? undefined : { kind: "response", info };
|
|
78
|
+
}
|
|
79
|
+
// The forwarding notice is the one kind that carries no mark, because it
|
|
80
|
+
// existed before the others did. So it is a request by position, and only
|
|
81
|
+
// when it names no kind at all: an item that names one and is not handled
|
|
82
|
+
// above must not be read as a request whose fields happen to line up.
|
|
83
|
+
if (kind !== undefined) return undefined;
|
|
84
|
+
const info = requestOf(raw);
|
|
85
|
+
return info === undefined ? undefined : { kind: "request", info };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function requestOf(raw: Record<string, unknown>): LlmRequestObservation | undefined {
|
|
89
|
+
const at = raw["ts"];
|
|
90
|
+
const sid = raw["session_id"];
|
|
91
|
+
if (!isInstant(at) || typeof sid !== "string" || sid === "") return undefined;
|
|
92
|
+
const info: Record<string, unknown> = { received_at: at, sid };
|
|
93
|
+
const prefix = raw["prefix"];
|
|
94
|
+
if (typeof prefix === "string" && prefix !== "") info["prefix"] = prefix;
|
|
95
|
+
for (const field of SAME_NAME_NUMBERS) {
|
|
96
|
+
const num = raw[field];
|
|
97
|
+
if (typeof num === "number" && Number.isFinite(num)) info[field] = num;
|
|
98
|
+
}
|
|
99
|
+
for (const [there, here] of RENAMED_INSTANTS) {
|
|
100
|
+
const num = raw[there];
|
|
101
|
+
if (isInstant(num)) info[here] = num;
|
|
102
|
+
}
|
|
103
|
+
for (const field of SAME_NAME_STRINGS) {
|
|
104
|
+
const text = raw[field];
|
|
105
|
+
if (typeof text === "string" && text !== "") info[field] = text;
|
|
106
|
+
}
|
|
107
|
+
const paused = raw["cache_paused"];
|
|
108
|
+
if (typeof paused === "boolean") info["cache_paused"] = paused;
|
|
109
|
+
const status = raw["status"];
|
|
110
|
+
if (typeof status === "number" && Number.isInteger(status)) info["status"] = status;
|
|
111
|
+
return info as unknown as LlmRequestObservation;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function responseOf(raw: Record<string, unknown>): LlmResponseObservation | undefined {
|
|
115
|
+
const at = raw["ts"];
|
|
116
|
+
const sid = raw["session_id"];
|
|
117
|
+
if (!isInstant(at) || typeof sid !== "string" || sid === "") return undefined;
|
|
118
|
+
return { sid, at };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A number that can be an instant on this wire. Rejecting a non-number is
|
|
122
|
+
* what stops an ISO string from reaching a `*_at` field. */
|
|
123
|
+
function isInstant(value: unknown): value is Timestamp {
|
|
124
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
125
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
Capability,
|
|
6
|
+
InstanceId,
|
|
7
|
+
LlmStatsReadArgs,
|
|
8
|
+
LlmStatsReadResult,
|
|
9
|
+
LlmUsageReadArgs,
|
|
10
|
+
LlmUsageReadResult,
|
|
11
|
+
Sid,
|
|
12
|
+
Timestamp,
|
|
13
|
+
} from "@ccmsg/protocol";
|
|
14
|
+
import type { HandlerInput } from "../dispatch/index.ts";
|
|
15
|
+
import { ConfigError, type UpstreamConfig } from "../instance/config.ts";
|
|
16
|
+
import type { Env } from "../instance/paths.ts";
|
|
17
|
+
import type { TopicValue, UpstreamResource } from "../topics/index.ts";
|
|
18
|
+
import { parseGatewayItem } from "./events.ts";
|
|
19
|
+
import { LlmRequests } from "./requests.ts";
|
|
20
|
+
import { readStats } from "./stats.ts";
|
|
21
|
+
import { LlmStatus } from "./status.ts";
|
|
22
|
+
import { readUsage } from "./usage.ts";
|
|
23
|
+
import { handleWebhook, SOURCE_NAME, type WebhookSource } from "./webhook.ts";
|
|
24
|
+
|
|
25
|
+
/** Where the gateway's own endpoints live under its address (DR-0006). */
|
|
26
|
+
const STATUS_PATH = "/llm-gateway/status";
|
|
27
|
+
const USAGE_PATH = "/llm-gateway/usage";
|
|
28
|
+
const STATS_PATH = "/llm-gateway/stats";
|
|
29
|
+
|
|
30
|
+
/** What the config says this instance can reach of the gateway, resolved.
|
|
31
|
+
*
|
|
32
|
+
* Both halves are independent: an instance can be posted to without being able
|
|
33
|
+
* to ask anything back, and the other way round. Each grants its own
|
|
34
|
+
* capability, so a client is told which of the two it has rather than
|
|
35
|
+
* discovering it by subscribing. */
|
|
36
|
+
export interface GatewaySetup {
|
|
37
|
+
/** Present when a webhook source is configured, with the secret it must
|
|
38
|
+
* present already read. */
|
|
39
|
+
readonly source?: { readonly name: string; readonly token: string };
|
|
40
|
+
/** Present when the gateway's address is configured. The three are one
|
|
41
|
+
* decision — the address — and are resolved together so a client is told
|
|
42
|
+
* about all three at once rather than discovering each by asking. */
|
|
43
|
+
readonly statusUrl?: string;
|
|
44
|
+
readonly usageUrl?: string;
|
|
45
|
+
readonly statsUrl?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read the upstream section (§8.2).
|
|
49
|
+
*
|
|
50
|
+
* A setting that is there and cannot be honoured ends the start rather than
|
|
51
|
+
* leaving the feature it asked for silently off (DV-Q9): an operator who named
|
|
52
|
+
* a webhook source wants the route, and an instance that came up without it
|
|
53
|
+
* looks identical to one nobody is posting to. A section that is absent is not
|
|
54
|
+
* broken — it states that this instance has no gateway, which costs the rows
|
|
55
|
+
* one attribute and nothing else (§5.2). */
|
|
56
|
+
export function gatewaySetup(config: UpstreamConfig, file: string, env: Env): GatewaySetup {
|
|
57
|
+
const name = config.gateway_webhook_source;
|
|
58
|
+
const url = config.gateway_url;
|
|
59
|
+
return {
|
|
60
|
+
...(name === undefined
|
|
61
|
+
? {}
|
|
62
|
+
: { source: { name: sourceName(file, name), token: token(file, config, env, name) } }),
|
|
63
|
+
...(url === undefined
|
|
64
|
+
? {}
|
|
65
|
+
: {
|
|
66
|
+
statusUrl: endpoint(file, url, STATUS_PATH),
|
|
67
|
+
usageUrl: endpoint(file, url, USAGE_PATH),
|
|
68
|
+
statsUrl: endpoint(file, url, STATS_PATH),
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The capabilities the setup grants. `llm_events` says request activity
|
|
74
|
+
* arrives to be pushed; the other three say the gateway can be asked for its
|
|
75
|
+
* report, its quota and its spend. */
|
|
76
|
+
export function gatewayCapabilities(setup: GatewaySetup): Capability[] {
|
|
77
|
+
return [
|
|
78
|
+
...(setup.source === undefined ? [] : (["llm_events"] as const)),
|
|
79
|
+
...(setup.statusUrl === undefined ? [] : (["llm_status"] as const)),
|
|
80
|
+
...(setup.usageUrl === undefined ? [] : (["llm_usage"] as const)),
|
|
81
|
+
...(setup.statsUrl === undefined ? [] : (["llm_stats"] as const)),
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The two ops that ask the gateway a question and answer with what it said.
|
|
86
|
+
*
|
|
87
|
+
* They are handlers rather than resources: neither has a current value to hold
|
|
88
|
+
* or a topic to push on, so each read is one question asked because a person
|
|
89
|
+
* asked it. */
|
|
90
|
+
export function gatewayHandlers(setup: GatewaySetup, fetcher?: typeof fetch) {
|
|
91
|
+
const call = fetcher === undefined ? {} : { fetch: fetcher };
|
|
92
|
+
const usageUrl = setup.usageUrl;
|
|
93
|
+
const statsUrl = setup.statsUrl;
|
|
94
|
+
return {
|
|
95
|
+
...(usageUrl === undefined
|
|
96
|
+
? {}
|
|
97
|
+
: {
|
|
98
|
+
llm_usage_read: (input: HandlerInput): Promise<LlmUsageReadResult> =>
|
|
99
|
+
readUsage({ url: usageUrl, ...call }, input.args as unknown as LlmUsageReadArgs),
|
|
100
|
+
}),
|
|
101
|
+
...(statsUrl === undefined
|
|
102
|
+
? {}
|
|
103
|
+
: {
|
|
104
|
+
llm_stats_read: (input: HandlerInput): Promise<LlmStatsReadResult> =>
|
|
105
|
+
readStats({ url: statsUrl, ...call }, input.args as unknown as LlmStatsReadArgs),
|
|
106
|
+
}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface GatewayDeps {
|
|
111
|
+
readonly self: InstanceId;
|
|
112
|
+
readonly setup: GatewaySetup;
|
|
113
|
+
readonly publish: (topic: string, data: unknown) => void;
|
|
114
|
+
/** The gateway saw something happen for a session, which is an input of the
|
|
115
|
+
* sessions domain (§5.1) rather than of either topic. */
|
|
116
|
+
readonly onActivity?: () => void;
|
|
117
|
+
readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
|
|
118
|
+
/** Replaces the outward read in tests. */
|
|
119
|
+
readonly fetch?: typeof fetch;
|
|
120
|
+
readonly settleMs?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** What this instance takes from the llm-gateway.
|
|
124
|
+
*
|
|
125
|
+
* One direction each. The gateway posts what it saw the moment it saw it,
|
|
126
|
+
* because it runs as more than one process and a subscription would only ever
|
|
127
|
+
* have reached whichever one it connected to; this instance asks for the
|
|
128
|
+
* service report, because that is a document with a current value rather than
|
|
129
|
+
* a stream of occurrences. Both arrive as events and neither is polled (M3):
|
|
130
|
+
* the report is re-read when a posted event says an upstream refused, which is
|
|
131
|
+
* this instance being told the one thing that changes it. */
|
|
132
|
+
export class Gateway {
|
|
133
|
+
readonly requests: LlmRequests;
|
|
134
|
+
readonly status: LlmStatus | undefined;
|
|
135
|
+
readonly #source: WebhookSource | undefined;
|
|
136
|
+
|
|
137
|
+
constructor(private readonly deps: GatewayDeps) {
|
|
138
|
+
this.requests = new LlmRequests({
|
|
139
|
+
self: deps.self,
|
|
140
|
+
publish: deps.publish,
|
|
141
|
+
...(deps.onActivity === undefined ? {} : { onActivity: deps.onActivity }),
|
|
142
|
+
});
|
|
143
|
+
this.status =
|
|
144
|
+
deps.setup.statusUrl === undefined
|
|
145
|
+
? undefined
|
|
146
|
+
: new LlmStatus({
|
|
147
|
+
self: deps.self,
|
|
148
|
+
url: deps.setup.statusUrl,
|
|
149
|
+
publish: deps.publish,
|
|
150
|
+
...(deps.log === undefined ? {} : { log: deps.log }),
|
|
151
|
+
...(deps.fetch === undefined ? {} : { fetch: deps.fetch }),
|
|
152
|
+
...(deps.settleMs === undefined ? {} : { settleMs: deps.settleMs }),
|
|
153
|
+
});
|
|
154
|
+
this.#source =
|
|
155
|
+
deps.setup.source === undefined
|
|
156
|
+
? undefined
|
|
157
|
+
: {
|
|
158
|
+
name: deps.setup.source.name,
|
|
159
|
+
token: deps.setup.source.token,
|
|
160
|
+
handle: (items) => this.take(items),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The resource behind `llm_status`. A stand-in that states nothing when the
|
|
165
|
+
* gateway's address is not configured — the topic's capability is absent
|
|
166
|
+
* then, so nothing reaches it, and the attachment stays unconditional. */
|
|
167
|
+
get statusResource(): UpstreamResource {
|
|
168
|
+
return this.status ?? SILENT;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** When the gateway last saw inference for a session (§5.1). */
|
|
172
|
+
activeAt(sid: Sid, now?: Timestamp): Timestamp | undefined {
|
|
173
|
+
return this.requests.activeAt(sid, now);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One HTTP request that reached this instance's entry, answered if it is
|
|
177
|
+
* the webhook and left alone if it is not. */
|
|
178
|
+
route(request: Request): Promise<Response | undefined> {
|
|
179
|
+
return handleWebhook(request, this.#source, this.deps.log);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Stop what is pending. Called from the stop order (§8.5). */
|
|
183
|
+
close(): void {
|
|
184
|
+
this.status?.stop();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** One posted batch. Every item is read on its own: a kind this instance
|
|
188
|
+
* does not use is passed over, and one it cannot read at all is counted, so
|
|
189
|
+
* a schema that moved shows up as a number instead of as silence. */
|
|
190
|
+
private take(items: readonly unknown[]): void {
|
|
191
|
+
let unreadable = 0;
|
|
192
|
+
for (const value of items) {
|
|
193
|
+
const item = parseGatewayItem(value);
|
|
194
|
+
if (item === undefined) {
|
|
195
|
+
unreadable += 1;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (item.kind === "request") {
|
|
199
|
+
this.requests.record(item.info);
|
|
200
|
+
this.status?.noteRequestStatus(item.info.status);
|
|
201
|
+
} else if (item.kind === "response") {
|
|
202
|
+
this.requests.note(item.info.sid, item.info.at);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (unreadable > 0) {
|
|
206
|
+
this.deps.log?.("dropped items a gateway delivery could not be read as", { unreadable });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A resource with nothing behind it: subscribing to its topic starts nothing
|
|
212
|
+
* and states nothing. */
|
|
213
|
+
const SILENT: UpstreamResource = {
|
|
214
|
+
start(): void {},
|
|
215
|
+
stop(): void {},
|
|
216
|
+
snapshot(): readonly TopicValue[] {
|
|
217
|
+
return [];
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
function sourceName(file: string, name: string): string {
|
|
222
|
+
if (!SOURCE_NAME.test(name)) {
|
|
223
|
+
throw new ConfigError(
|
|
224
|
+
file,
|
|
225
|
+
"upstream.gateway_webhook_source must be lowercase letters, digits and dashes",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return name;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** One of the gateway's endpoints under the configured address. */
|
|
232
|
+
function endpoint(file: string, url: string, path: string): string {
|
|
233
|
+
let base: URL;
|
|
234
|
+
try {
|
|
235
|
+
base = new URL(url);
|
|
236
|
+
} catch {
|
|
237
|
+
throw new ConfigError(file, `upstream.gateway_url must be a URL, got ${url}`);
|
|
238
|
+
}
|
|
239
|
+
if (base.protocol !== "http:" && base.protocol !== "https:") {
|
|
240
|
+
throw new ConfigError(file, "upstream.gateway_url must be an http:// or https:// address");
|
|
241
|
+
}
|
|
242
|
+
return `${base.origin}${base.pathname.replace(/\/+$/, "")}${path}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The secret the gateway presents.
|
|
246
|
+
*
|
|
247
|
+
* It is the gateway that writes the file and this instance that reads it, so
|
|
248
|
+
* the default is the path the gateway defaults to (DR-0012) rather than one of
|
|
249
|
+
* this instance's own — a path per config home would have to be configured on
|
|
250
|
+
* both sides to mean the same thing. */
|
|
251
|
+
function token(file: string, config: UpstreamConfig, env: Env, source: string): string {
|
|
252
|
+
const path = config.gateway_webhook_token_file ?? defaultTokenFile(env, source);
|
|
253
|
+
let raw: string;
|
|
254
|
+
try {
|
|
255
|
+
raw = readFileSync(path, "utf8");
|
|
256
|
+
} catch (cause) {
|
|
257
|
+
throw new ConfigError(
|
|
258
|
+
file,
|
|
259
|
+
`upstream.gateway_webhook_source is set but its token file ${path} could not be read (${String(cause)})`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const token = raw.trim();
|
|
263
|
+
if (token === "") throw new ConfigError(file, `the webhook token file ${path} is empty`);
|
|
264
|
+
return token;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function defaultTokenFile(env: Env, source: string): string {
|
|
268
|
+
const xdg = env["XDG_DATA_HOME"];
|
|
269
|
+
const home = env["HOME"];
|
|
270
|
+
const base =
|
|
271
|
+
xdg !== undefined && isAbsolute(xdg)
|
|
272
|
+
? xdg
|
|
273
|
+
: join(home !== undefined && isAbsolute(home) ? home : homedir(), ".local", "share");
|
|
274
|
+
return join(base, "ccmsg", `webhook-${source}.token`);
|
|
275
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Timestamp } from "@ccmsg/protocol";
|
|
2
|
+
|
|
3
|
+
/** What every read of an upstream document is built from (§3.5).
|
|
4
|
+
*
|
|
5
|
+
* The gateway's three documents are read the same way — a field is taken only
|
|
6
|
+
* at the type this contract states for it, and anything else is absent — so the
|
|
7
|
+
* readers live here once rather than once per document. */
|
|
8
|
+
|
|
9
|
+
export interface FetchOptions {
|
|
10
|
+
/** How long the read is given before it is one that is not coming. */
|
|
11
|
+
readonly timeoutMs: number;
|
|
12
|
+
/** Cap on the document, since the whole of it is held to be parsed. */
|
|
13
|
+
readonly maxBytes: number;
|
|
14
|
+
/** Replaces the outward read in tests. */
|
|
15
|
+
readonly fetch?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** One JSON document from the gateway, or a throw saying why not. */
|
|
19
|
+
export async function fetchJson(url: string, options: FetchOptions): Promise<unknown> {
|
|
20
|
+
const call = options.fetch ?? fetch;
|
|
21
|
+
const answer = await call(url, {
|
|
22
|
+
signal: AbortSignal.timeout(options.timeoutMs),
|
|
23
|
+
headers: { accept: "application/json" },
|
|
24
|
+
});
|
|
25
|
+
if (!answer.ok) throw new Error(`the gateway answered ${answer.status}`);
|
|
26
|
+
return JSON.parse(await bounded(answer, options.maxBytes)) as unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function bounded(answer: Response, maxBytes: number): Promise<string> {
|
|
30
|
+
const body = answer.body;
|
|
31
|
+
if (body === null) return "";
|
|
32
|
+
const reader = body.getReader();
|
|
33
|
+
const decoder = new TextDecoder();
|
|
34
|
+
let size = 0;
|
|
35
|
+
let text = "";
|
|
36
|
+
try {
|
|
37
|
+
for (;;) {
|
|
38
|
+
const { done, value } = await reader.read();
|
|
39
|
+
if (done === true) break;
|
|
40
|
+
size += value.byteLength;
|
|
41
|
+
if (size > maxBytes) throw new Error(`the document is over ${maxBytes} bytes`);
|
|
42
|
+
text += decoder.decode(value, { stream: true });
|
|
43
|
+
}
|
|
44
|
+
} finally {
|
|
45
|
+
reader.releaseLock();
|
|
46
|
+
}
|
|
47
|
+
return text + decoder.decode();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function objectOf(value: unknown): Record<string, unknown> | undefined {
|
|
51
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
52
|
+
return value as Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function oneOf<T extends string>(allowed: readonly T[], value: unknown, fallback: T): T {
|
|
56
|
+
return typeof value === "string" && (allowed as readonly string[]).includes(value)
|
|
57
|
+
? (value as T)
|
|
58
|
+
: fallback;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function optionalText(name: string, value: unknown): Record<string, string> {
|
|
62
|
+
return typeof value === "string" ? { [name]: value } : {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function optionalInteger(name: string, value: unknown): Record<string, number> {
|
|
66
|
+
return typeof value === "number" && Number.isInteger(value) ? { [name]: value } : {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function optionalNumber(name: string, value: unknown): Record<string, number> {
|
|
70
|
+
return typeof value === "number" && Number.isFinite(value) ? { [name]: value } : {};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function optionalFlag(name: string, value: unknown): Record<string, boolean> {
|
|
74
|
+
return typeof value === "boolean" ? { [name]: value } : {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An instant, taken only as a number. Rejecting a string is what stops an ISO
|
|
78
|
+
* time from travelling on a field this contract says is Unix ms. */
|
|
79
|
+
export function optionalInstant(name: string, value: unknown): Record<string, Timestamp> {
|
|
80
|
+
return typeof value === "number" && Number.isFinite(value) ? { [name]: value } : {};
|
|
81
|
+
}
|