deepdebug-local-agent 1.9.0 → 1.11.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/AGENTS.md +1 -1
- package/CHANGELOG.md +5 -0
- package/CLAUDE.md +1 -1
- package/docs/examples/agent2-runner/docker-compose.yml +53 -0
- package/docs/la-agent2-runner.md +115 -0
- package/docs/la-jobs-and-llm.md +62 -0
- package/docs/la-protocol-v2.md +7 -5
- package/env.example +12 -0
- package/package.json +1 -1
- package/src/la-protocol-v2/content-handlers/install.js +27 -1
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +10 -6
- package/src/la-protocol-v2/contract/README.md +75 -2
- package/src/la-protocol-v2/contract/envelope.schema.json +3 -1
- package/src/la-protocol-v2/contract/index.json +18 -2
- package/src/la-protocol-v2/contract/job.assign.schema.json +31 -0
- package/src/la-protocol-v2/contract/policy.check.schema.json +97 -0
- package/src/la-protocol-v2/contract/policy.decision.schema.json +119 -0
- package/src/la-protocol-v2/jobs/agent2/api-client.js +150 -0
- package/src/la-protocol-v2/jobs/agent2/approvals.js +114 -0
- package/src/la-protocol-v2/jobs/agent2/bridge.js +299 -0
- package/src/la-protocol-v2/jobs/agent2/config.js +128 -0
- package/src/la-protocol-v2/jobs/agent2/frames.js +61 -0
- package/src/la-protocol-v2/jobs/agent2/policy-check.js +170 -0
- package/src/la-protocol-v2/jobs/agent2/runner.js +258 -0
- package/src/la-protocol-v2/jobs/agent2/supervisor.js +269 -0
- package/src/la-protocol-v2/jobs/config.js +60 -0
- package/src/la-protocol-v2/jobs/events.js +106 -0
- package/src/la-protocol-v2/jobs/install.js +43 -0
- package/src/la-protocol-v2/jobs/manager.js +281 -0
- package/src/la-protocol-v2/jobs/options.js +56 -0
- package/src/la-protocol-v2/jobs/smoke-runner.js +39 -0
- package/src/la-protocol-v2/llm/client.js +432 -0
- package/src/la-protocol-v2/llm/config.js +63 -0
- package/src/la-protocol-v2/llm/install.js +17 -0
- package/src/la-protocol-v2/llm-key/store.js +10 -0
- package/src/la-protocol-v2/runtime.js +69 -3
- package/src/server.js +4 -0
- package/src/utils/approval-token.js +3 -2
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +1538 -0
- package/test/contract/la-protocol-v2/vectors/policy.check.vectors.json +908 -0
- package/test/contract/la-protocol-v2/vectors/policy.decision.vectors.json +826 -0
- package/test/la-protocol-v2/agent2-approval-discovery.test.js +114 -0
- package/test/la-protocol-v2/agent2-config.test.js +103 -0
- package/test/la-protocol-v2/agent2-orphan.test.js +118 -0
- package/test/la-protocol-v2/agent2-policy-check.test.js +273 -0
- package/test/la-protocol-v2/agent2-real-e2e.test.js +89 -0
- package/test/la-protocol-v2/agent2-runner.test.js +510 -0
- package/test/la-protocol-v2/agent2-supervisor.test.js +185 -0
- package/test/la-protocol-v2/agent2-workspace-door.test.js +142 -0
- package/test/la-protocol-v2/agent2-workspace-real-server.test.js +137 -0
- package/test/la-protocol-v2/contract-v21.test.js +2 -2
- package/test/la-protocol-v2/contract.test.js +8 -7
- package/test/la-protocol-v2/helpers/agent2-rig.js +96 -0
- package/test/la-protocol-v2/helpers/fake-agent2-cli.js +22 -0
- package/test/la-protocol-v2/helpers/fake-agent2.js +280 -0
- package/test/la-protocol-v2/helpers/fake-la-api.js +29 -0
- package/test/la-protocol-v2/helpers/fake-llm.js +75 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +6 -3
- package/test/la-protocol-v2/helpers/spawn-agent.js +4 -3
- package/test/la-protocol-v2/helpers/supervisor-exit-script.js +15 -0
- package/test/la-protocol-v2/jobs-e2e.test.js +405 -0
- package/test/la-protocol-v2/jobs-manager.test.js +370 -0
- package/test/la-protocol-v2/jobs-options.test.js +288 -0
- package/test/la-protocol-v2/llm-client.test.js +380 -0
- package/test/la-protocol-v2/llm-messages.test.js +132 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client.js - the Local Agent's LLM client (F3-10). One job: send a chat to the AI gateway the control plane named, with the virtual key the
|
|
3
|
+
* llm-key holder keeps, and give back the text and the usage. OpenAI-compatible `chat/completions`, optional streaming.
|
|
4
|
+
*
|
|
5
|
+
* Order of every call (nothing leaves before the DLP, nothing is read before the checks):
|
|
6
|
+
* 1. shape of the request (model name, messages) -> `bad_request`
|
|
7
|
+
* 2. DLP must be `enforce` (read per call) -> `dlp_not_enforced`
|
|
8
|
+
* 3. a valid key is held and the model is one of its aliases -> `no_key` | `key_expired` | `model_not_allowed`
|
|
9
|
+
* 4. EVERY message is masked with the agent's DLP (src/dlp); a hit of a blocked class, a DLP failure or an oversize input is refused
|
|
10
|
+
* (`dlp_blocked` | `dlp_denied`); the masked prompt is capped (`prompt_too_large`) and checked against the token budget (`budget_exceeded`)
|
|
11
|
+
* 5. a concurrency slot (`busy`), then the HTTP call through `holder.request()` (the ONLY way out for the key: https endpoint from the
|
|
12
|
+
* control plane, `Authorization` header set by the holder, no redirects) with a timeout per attempt, a total deadline and a few
|
|
13
|
+
* retries (network error, 408, 429, 5xx; never after a streamed delta was delivered; never a 4xx)
|
|
14
|
+
* 6. the response is read under a byte cap; errors are CODES only
|
|
15
|
+
*
|
|
16
|
+
* What it never does: log, audit, throw or return a prompt, a response, the key, its id or the body of an error response. The audit record
|
|
17
|
+
* has the alias, byte counts, token counts, per-rule redaction COUNTS and a short hash of the job id. Tags (`metadata.tags`) carry ids only.
|
|
18
|
+
*/
|
|
19
|
+
import { dlpConfigFromEnv, protectEgress, DENIED } from "../../dlp/index.js";
|
|
20
|
+
import { OVERSIZE } from "../../dlp/engine.js";
|
|
21
|
+
import { createAudit, short } from "../content-handlers/audit.js";
|
|
22
|
+
|
|
23
|
+
/** The complete list of error codes of this client (a test pins it: no free text ever travels in an error). */
|
|
24
|
+
export const LLM_ERROR_CODES = Object.freeze([
|
|
25
|
+
"bad_request", "dlp_not_enforced", "no_key", "key_expired", "model_not_allowed", "dlp_blocked", "dlp_denied", "prompt_too_large",
|
|
26
|
+
"budget_exceeded", "busy", "key_rejected", "rate_limited", "timeout", "network_error", "upstream_error", "response_too_large",
|
|
27
|
+
"bad_response", "cancelled", "internal",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export class LlmClientError extends Error {
|
|
31
|
+
constructor(code, { attempts = 0 } = {}) {
|
|
32
|
+
const known = LLM_ERROR_CODES.includes(code) ? code : "internal"; // the message is the code, never free text
|
|
33
|
+
super(known);
|
|
34
|
+
this.name = "LlmClientError";
|
|
35
|
+
this.code = known;
|
|
36
|
+
this.attempts = attempts;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MODEL = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,95}$/;
|
|
41
|
+
const TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
42
|
+
const TOOL_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
43
|
+
const ROLES = new Set(["system", "user", "assistant"]);
|
|
44
|
+
const COST_HEADER = "x-litellm-response-cost";
|
|
45
|
+
const MAX_SSE_LINE_BYTES = 256 * 1024;
|
|
46
|
+
const utf8 = new TextEncoder();
|
|
47
|
+
const byteLength = (s) => utf8.encode(s).length;
|
|
48
|
+
const estimateTokens = (bytes) => Math.ceil(bytes / 3); // pessimistic (code and non-English text are denser than 4 bytes/token)
|
|
49
|
+
|
|
50
|
+
const defaultSleep = (ms, signal) => new Promise((resolve) => {
|
|
51
|
+
const t = setTimeout(done, ms);
|
|
52
|
+
function done() { signal?.removeEventListener("abort", done); clearTimeout(t); resolve(); }
|
|
53
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function addCounts(into, from) { for (const [k, v] of Object.entries(from || {})) into[k] = (into[k] || 0) + v; }
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {object} o
|
|
60
|
+
* @param {{request:Function, describe:Function, held:boolean}} o.holder the llm-key holder (link.llm.holder)
|
|
61
|
+
* @param {object} o.config resolveLlmClientConfig()
|
|
62
|
+
* @param {string} o.tenantId @param {string} o.agentId only for the request tags
|
|
63
|
+
* @param {object} [o.env] LOCAL_AGENT_DLP_* (read per call)
|
|
64
|
+
* @param {object} [o.audit] createAudit() (a ring by default)
|
|
65
|
+
* @param {Function} [o.fetchImpl] tests only; production uses globalThis.fetch through the holder
|
|
66
|
+
*/
|
|
67
|
+
export function createLlmClient({ holder, config, tenantId, agentId, env = process.env, audit = null, now = Date.now, sleep = defaultSleep, fetchImpl = undefined }) {
|
|
68
|
+
const auditor = audit || createAudit({ file: config.auditFile, now });
|
|
69
|
+
const stats = { calls: 0, ok: 0, failed: 0, retries: 0, bytesOut: 0, bytesIn: 0, tokensIn: 0, tokensOut: 0, byCode: {} };
|
|
70
|
+
let active = 0;
|
|
71
|
+
|
|
72
|
+
const record = (rec) => { try { auditor.record({ event: "llm_call", ...rec }); } catch { /* the audit never breaks the data path */ } };
|
|
73
|
+
const fail = (code, ctx, attempts = 0) => {
|
|
74
|
+
const err = new LlmClientError(code, { attempts });
|
|
75
|
+
stats.failed++;
|
|
76
|
+
stats.byCode[err.code] = (stats.byCode[err.code] || 0) + 1;
|
|
77
|
+
record({ ok: false, code: err.code, model: ctx.model, jobShort: ctx.jobShort, promptBytes: ctx.promptBytes, attempts, redactions: ctx.redactions, ms: now() - ctx.t0, stream: ctx.stream });
|
|
78
|
+
throw err;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Masks every message; returns the masked messages, their size and the per-rule counts. Throws a code (string) on refusal. */
|
|
82
|
+
function maskMessages(messages, ctx) {
|
|
83
|
+
const out = [];
|
|
84
|
+
let bytes = 0;
|
|
85
|
+
for (const m of messages) {
|
|
86
|
+
const res = protectEgress(m.content, { env, source: "llm.client" });
|
|
87
|
+
addCounts(ctx.redactions, res.redactions);
|
|
88
|
+
if (res.denied || res.text === DENIED || res.text === OVERSIZE) throw "dlp_denied";
|
|
89
|
+
if (res.blocked) throw "dlp_blocked";
|
|
90
|
+
bytes += byteLength(res.text);
|
|
91
|
+
if (bytes > config.maxPromptBytes) throw "prompt_too_large";
|
|
92
|
+
out.push({ role: m.role, content: res.text });
|
|
93
|
+
}
|
|
94
|
+
return { masked: out, bytes };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseCost(headers) {
|
|
98
|
+
const raw = headers && typeof headers.get === "function" ? headers.get(COST_HEADER) : null;
|
|
99
|
+
const n = raw === null || raw === undefined || raw === "" ? NaN : Number(raw);
|
|
100
|
+
return Number.isFinite(n) && n >= 0 && n <= 100_000 ? n : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One attempt. Returns the parsed answer, or throws { code, retry, after? } (never text). */
|
|
104
|
+
async function attempt(bodyText, o, ctx) {
|
|
105
|
+
const ac = new AbortController();
|
|
106
|
+
let reason = null;
|
|
107
|
+
const abort = (why) => { if (!reason) { reason = why; ac.abort(); } };
|
|
108
|
+
const onExternal = () => abort("cancelled");
|
|
109
|
+
if (o.signal) { if (o.signal.aborted) throw { code: "cancelled", retry: false }; o.signal.addEventListener("abort", onExternal, { once: true }); }
|
|
110
|
+
let timer = setTimeout(() => abort("timeout"), Math.min(config.timeoutMs, Math.max(1, ctx.deadline - now())));
|
|
111
|
+
const rearm = (ms) => { clearTimeout(timer); timer = setTimeout(() => abort("timeout"), Math.min(ms, Math.max(1, ctx.deadline - now()))); };
|
|
112
|
+
const lift = (e) => ({ code: reason === "cancelled" ? "cancelled" : reason === "timeout" ? "timeout" : e?.code && LLM_ERROR_CODES.includes(e.code) ? e.code : "network_error", retry: reason !== "cancelled" });
|
|
113
|
+
try {
|
|
114
|
+
let res;
|
|
115
|
+
try {
|
|
116
|
+
res = await holder.request(o.path || config.path, { method: "POST", headers: { "content-type": "application/json", accept: o.stream ? "text/event-stream" : "application/json" }, body: bodyText, signal: ac.signal }, fetchImpl ? { fetchImpl } : undefined);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
// LlmKeyError codes from the holder (no_key | expired | bad_path); anything else (fetch failure, abort) is a network error
|
|
119
|
+
if (e && e.name === "LlmKeyError") throw { code: e.code === "expired" ? "key_expired" : e.code === "no_key" ? "no_key" : "internal", retry: false };
|
|
120
|
+
throw lift(e);
|
|
121
|
+
}
|
|
122
|
+
const status = res.status;
|
|
123
|
+
if (status < 200 || status >= 300) {
|
|
124
|
+
try { await res.body?.cancel(); } catch { /* the body of an error response is never read: it may echo the prompt */ }
|
|
125
|
+
const ra = res.headers?.get?.("retry-after");
|
|
126
|
+
const retryAfter = ra === null || ra === undefined || ra === "" ? NaN : Number(ra); // seconds; an HTTP date is ignored
|
|
127
|
+
const after = Number.isFinite(retryAfter) && retryAfter >= 0 ? Math.min(retryAfter * 1000, config.maxBackoffMs) : undefined;
|
|
128
|
+
if (status === 401 || status === 403) throw { code: "key_rejected", retry: false };
|
|
129
|
+
if (status === 408) throw { code: "timeout", retry: true, after };
|
|
130
|
+
if (status === 429) throw { code: "rate_limited", retry: true, after };
|
|
131
|
+
if (status === 413) throw { code: "prompt_too_large", retry: false };
|
|
132
|
+
if (status >= 500) throw { code: "upstream_error", retry: true, after };
|
|
133
|
+
throw { code: "bad_request", retry: false }; // other 4xx: the request itself is wrong (alias unknown to the proxy, schema): repeating it changes nothing
|
|
134
|
+
}
|
|
135
|
+
const costUsd = parseCost(res.headers);
|
|
136
|
+
if (!res.body || typeof res.body.getReader !== "function") throw { code: "bad_response", retry: false };
|
|
137
|
+
try {
|
|
138
|
+
const parsed = o.stream ? await readStream(res.body, o, rearm, ctx) : o.parseJson ? await o.parseJson(res.body, config.maxResponseBytes) : await readJson(res.body, config.maxResponseBytes);
|
|
139
|
+
return { ...parsed, costUsd };
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (e && e.code && !e.name) throw e; // our own { code, retry }
|
|
142
|
+
throw lift(e);
|
|
143
|
+
}
|
|
144
|
+
} finally {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
o.signal?.removeEventListener("abort", onExternal);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function readBounded(body, maxBytes) {
|
|
151
|
+
const reader = body.getReader();
|
|
152
|
+
const chunks = [];
|
|
153
|
+
let total = 0;
|
|
154
|
+
for (;;) {
|
|
155
|
+
const { done, value } = await reader.read();
|
|
156
|
+
if (done) break;
|
|
157
|
+
total += value.byteLength;
|
|
158
|
+
if (total > maxBytes) { try { await reader.cancel(); } catch { /* gone */ } throw { code: "response_too_large", retry: false }; }
|
|
159
|
+
chunks.push(value);
|
|
160
|
+
}
|
|
161
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))).toString("utf8");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function readJson(body, maxBytes) {
|
|
165
|
+
const text = await readBounded(body, maxBytes);
|
|
166
|
+
let j;
|
|
167
|
+
try { j = JSON.parse(text); } catch { throw { code: "bad_response", retry: false }; }
|
|
168
|
+
const choice = j && Array.isArray(j.choices) ? j.choices[0] : null;
|
|
169
|
+
const msg = choice && choice.message;
|
|
170
|
+
if (!choice || !msg || (msg.content !== null && msg.content !== undefined && typeof msg.content !== "string")) throw { code: "bad_response", retry: false };
|
|
171
|
+
return { text: msg.content || "", usage: usageOf(j.usage), model: modelOf(j.model), finishReason: typeof choice.finish_reason === "string" ? choice.finish_reason.slice(0, 32) : null };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function readStream(body, o, rearm, ctx) {
|
|
175
|
+
const reader = body.getReader();
|
|
176
|
+
const dec = new TextDecoder("utf-8");
|
|
177
|
+
let buf = "";
|
|
178
|
+
let text = "";
|
|
179
|
+
let textBytes = 0;
|
|
180
|
+
let usage = null;
|
|
181
|
+
let model = null;
|
|
182
|
+
let finish = null;
|
|
183
|
+
let done = false;
|
|
184
|
+
const handleData = (data) => {
|
|
185
|
+
if (data === "[DONE]") { done = true; return; }
|
|
186
|
+
let j;
|
|
187
|
+
try { j = JSON.parse(data); } catch { throw { code: "bad_response", retry: false }; }
|
|
188
|
+
if (j && j.error) throw { code: "upstream_error", retry: false };
|
|
189
|
+
if (j && j.usage) usage = usageOf(j.usage);
|
|
190
|
+
if (j && typeof j.model === "string") model = modelOf(j.model);
|
|
191
|
+
const c = j && Array.isArray(j.choices) ? j.choices[0] : null;
|
|
192
|
+
if (!c) return;
|
|
193
|
+
if (typeof c.finish_reason === "string") finish = c.finish_reason.slice(0, 32);
|
|
194
|
+
const d = c.delta && typeof c.delta.content === "string" ? c.delta.content : "";
|
|
195
|
+
if (d) {
|
|
196
|
+
textBytes += byteLength(d);
|
|
197
|
+
if (textBytes > config.maxResponseBytes) throw { code: "response_too_large", retry: false };
|
|
198
|
+
text += d;
|
|
199
|
+
ctx.delivered = true;
|
|
200
|
+
if (typeof o.onDelta === "function") { try { o.onDelta(d); } catch { /* a consumer's bug never breaks the call */ } }
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const handleLine = (line) => { if (line.startsWith("data:")) handleData(line.slice(5).trim()); };
|
|
204
|
+
rearm(config.streamIdleMs);
|
|
205
|
+
while (!done) {
|
|
206
|
+
const { done: eof, value } = await reader.read();
|
|
207
|
+
if (eof) break;
|
|
208
|
+
rearm(config.streamIdleMs); // silence, not total time, is what the idle timeout measures (the total deadline still caps it)
|
|
209
|
+
buf += dec.decode(value, { stream: true });
|
|
210
|
+
let nl;
|
|
211
|
+
while ((nl = buf.search(/\r?\n/)) >= 0) {
|
|
212
|
+
const line = buf.slice(0, nl);
|
|
213
|
+
buf = buf.slice(buf[nl] === "\r" ? nl + 2 : nl + 1);
|
|
214
|
+
handleLine(line);
|
|
215
|
+
if (done) break;
|
|
216
|
+
}
|
|
217
|
+
if (buf.length > MAX_SSE_LINE_BYTES) throw { code: "bad_response", retry: false };
|
|
218
|
+
}
|
|
219
|
+
if (!done && buf.trim()) handleLine(buf.trim());
|
|
220
|
+
try { await reader.cancel(); } catch { /* closed */ }
|
|
221
|
+
if (!done && !finish && !text && !usage) throw { code: "bad_response", retry: false };
|
|
222
|
+
return { text, usage, model, finishReason: finish };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function usageOf(u) {
|
|
226
|
+
if (!u || typeof u !== "object") return null;
|
|
227
|
+
const p = Number(u.prompt_tokens);
|
|
228
|
+
const c = Number(u.completion_tokens);
|
|
229
|
+
return Number.isSafeInteger(p) && p >= 0 && Number.isSafeInteger(c) && c >= 0 ? { promptTokens: p, completionTokens: c, estimated: false } : null;
|
|
230
|
+
}
|
|
231
|
+
const modelOf = (m) => (typeof m === "string" && MODEL.test(m) ? m : null);
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @param {object} o
|
|
235
|
+
* @param {string} o.model an alias of the key (`link.llm.holder.describe().models`)
|
|
236
|
+
* @param {{role:"system"|"user"|"assistant",content:string}[]} o.messages
|
|
237
|
+
* @param {number} [o.maxTokens] capped by the config and by what is left of the budget
|
|
238
|
+
* @param {number} [o.temperature]
|
|
239
|
+
* @param {boolean} [o.stream] @param {(delta:string)=>void} [o.onDelta]
|
|
240
|
+
* @param {AbortSignal} [o.signal] cancellation (job.cancel, deadline)
|
|
241
|
+
* @param {{jobId?:string, agent?:string}} [o.job] tags only
|
|
242
|
+
* @param {number} [o.tokensLeft] what is left of the job budget (prompt estimate + max_tokens must fit)
|
|
243
|
+
* @returns {Promise<{text:string, usage:{promptTokens:number,completionTokens:number,estimated:boolean}, costUsd:number|null, model:string, finishReason:string|null, attempts:number}>}
|
|
244
|
+
*/
|
|
245
|
+
async function chat(o = {}) {
|
|
246
|
+
stats.calls++;
|
|
247
|
+
const ctx = { t0: now(), model: typeof o.model === "string" && MODEL.test(o.model) ? o.model : "invalid", jobShort: o.job?.jobId ? short(o.job.jobId) : undefined, promptBytes: 0, redactions: {}, stream: !!o.stream, delivered: false, deadline: now() + config.totalMs };
|
|
248
|
+
// 1. shape
|
|
249
|
+
const msgs = o.messages;
|
|
250
|
+
if (typeof o.model !== "string" || !MODEL.test(o.model) || !Array.isArray(msgs) || msgs.length < 1 || msgs.length > config.maxMessages
|
|
251
|
+
|| !msgs.every((m) => m && typeof m === "object" && ROLES.has(m.role) && typeof m.content === "string")) return fail("bad_request", ctx);
|
|
252
|
+
if (o.temperature !== undefined && !(Number.isFinite(o.temperature) && o.temperature >= 0 && o.temperature <= 2)) return fail("bad_request", ctx);
|
|
253
|
+
if (o.signal?.aborted) return fail("cancelled", ctx);
|
|
254
|
+
// 2. DLP enforce, per call
|
|
255
|
+
if (dlpConfigFromEnv(env).mode !== "enforce") return fail("dlp_not_enforced", ctx);
|
|
256
|
+
// 3. key and alias
|
|
257
|
+
if (!holder.held) return fail("no_key", ctx);
|
|
258
|
+
const d = holder.describe();
|
|
259
|
+
if (!d.held) return fail("key_expired", ctx);
|
|
260
|
+
if (!d.models.includes(o.model)) return fail("model_not_allowed", ctx);
|
|
261
|
+
// 4. mask first, then size and budget
|
|
262
|
+
let masked;
|
|
263
|
+
try {
|
|
264
|
+
const r = maskMessages(msgs, ctx);
|
|
265
|
+
masked = r.masked; ctx.promptBytes = r.bytes;
|
|
266
|
+
} catch (code) {
|
|
267
|
+
return fail(typeof code === "string" ? code : "dlp_denied", ctx);
|
|
268
|
+
}
|
|
269
|
+
let maxTokens = Math.min(Number.isSafeInteger(o.maxTokens) && o.maxTokens > 0 ? o.maxTokens : config.maxOutputTokens, config.maxOutputTokens);
|
|
270
|
+
if (o.tokensLeft !== undefined) {
|
|
271
|
+
const room = o.tokensLeft - estimateTokens(ctx.promptBytes);
|
|
272
|
+
if (!(room >= 1)) return fail("budget_exceeded", ctx);
|
|
273
|
+
maxTokens = Math.min(maxTokens, room);
|
|
274
|
+
}
|
|
275
|
+
// 5. slot and the call
|
|
276
|
+
return dispatch(o, ctx, {}, (tags) => JSON.stringify({
|
|
277
|
+
model: o.model, messages: masked, max_tokens: maxTokens, stream: !!o.stream,
|
|
278
|
+
...(o.stream ? { stream_options: { include_usage: true } } : {}),
|
|
279
|
+
...(o.temperature !== undefined ? { temperature: o.temperature } : {}),
|
|
280
|
+
...(tags ? { metadata: tags } : {}),
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The slot, the HTTP call with its retries and the bookkeeping, shared by chat() and messages(). `hooks` = { path, parseJson } of the API in use. */
|
|
285
|
+
async function dispatch(o, ctx, hooks, makeBody) {
|
|
286
|
+
if (active >= config.maxConcurrent) return fail("busy", ctx);
|
|
287
|
+
active++;
|
|
288
|
+
try {
|
|
289
|
+
const tags = config.tags ? { tags: [`vh-tenant:${tenantId}`, `vh-la:${agentId}`, ...(o.job?.jobId ? [`vh-job:${o.job.jobId}`] : []), ...(o.job?.agent && /^[a-z0-9][a-z0-9._-]{0,31}$/.test(o.job.agent) ? [`vh-agent:${o.job.agent}`] : [])] } : null;
|
|
290
|
+
const bodyText = makeBody(tags);
|
|
291
|
+
stats.bytesOut += byteLength(bodyText);
|
|
292
|
+
let attempts = 0;
|
|
293
|
+
for (;;) {
|
|
294
|
+
attempts++;
|
|
295
|
+
try {
|
|
296
|
+
const r = await attempt(bodyText, { ...o, ...hooks }, ctx);
|
|
297
|
+
const usage = r.usage || { promptTokens: estimateTokens(ctx.promptBytes), completionTokens: estimateTokens(byteLength(r.text)), estimated: true };
|
|
298
|
+
stats.ok++; stats.tokensIn += usage.promptTokens; stats.tokensOut += usage.completionTokens; stats.bytesIn += byteLength(r.text);
|
|
299
|
+
record({ ok: true, model: o.model, jobShort: ctx.jobShort, promptBytes: ctx.promptBytes, outBytes: byteLength(r.text), tokensIn: usage.promptTokens, tokensOut: usage.completionTokens, estimated: usage.estimated, attempts, redactions: ctx.redactions, ms: now() - ctx.t0, stream: !!o.stream, ...(hooks.api ? { api: hooks.api } : {}) });
|
|
300
|
+
return { text: r.text, usage, costUsd: r.costUsd, model: r.model || o.model, finishReason: r.finishReason, attempts, ...(r.message ? { message: r.message } : {}) };
|
|
301
|
+
} catch (e) {
|
|
302
|
+
const code = e && typeof e.code === "string" && LLM_ERROR_CODES.includes(e.code) ? e.code : "internal";
|
|
303
|
+
const canRetry = e && e.retry === true && !ctx.delivered && attempts <= config.maxRetries && !o.signal?.aborted;
|
|
304
|
+
if (!canRetry) return fail(code, ctx, attempts);
|
|
305
|
+
const wait = Math.min(e.after ?? config.backoffMs * 2 ** (attempts - 1), config.maxBackoffMs);
|
|
306
|
+
if (now() + wait >= ctx.deadline) return fail(code, ctx, attempts);
|
|
307
|
+
stats.retries++;
|
|
308
|
+
await sleep(wait, o.signal);
|
|
309
|
+
if (o.signal?.aborted) return fail("cancelled", ctx, attempts);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} finally {
|
|
313
|
+
active--;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ------------------------------------------------------------------------------------------------ Anthropic Messages API (F3-08c)
|
|
318
|
+
|
|
319
|
+
/** Masks one string and adds its size; refuses (throws a code) like maskMessages. */
|
|
320
|
+
function maskText(text, ctx, acc) {
|
|
321
|
+
const res = protectEgress(text, { env, source: "llm.client" });
|
|
322
|
+
addCounts(ctx.redactions, res.redactions);
|
|
323
|
+
if (res.denied || res.text === DENIED || res.text === OVERSIZE) throw "dlp_denied";
|
|
324
|
+
if (res.blocked) throw "dlp_blocked";
|
|
325
|
+
acc.bytes += byteLength(res.text);
|
|
326
|
+
if (acc.bytes > config.maxPromptBytes) throw "prompt_too_large";
|
|
327
|
+
return res.text;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Masks every string leaf of a JSON value (tool input), bounded in depth and size. */
|
|
331
|
+
function maskJson(v, ctx, acc, depth = 0) {
|
|
332
|
+
if (depth > 8 || ++acc.nodes > 20_000) throw "bad_request";
|
|
333
|
+
if (typeof v === "string") return maskText(v, ctx, acc);
|
|
334
|
+
if (v === null || typeof v === "number" || typeof v === "boolean") return v;
|
|
335
|
+
if (Array.isArray(v)) return v.map((x) => maskJson(x, ctx, acc, depth + 1));
|
|
336
|
+
if (typeof v === "object") { const out = {}; for (const [k, x] of Object.entries(v)) { if (k === "__proto__" || k.length > 200) throw "bad_request"; out[k] = maskJson(x, ctx, acc, depth + 1); } return out; }
|
|
337
|
+
throw "bad_request";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const cacheOf = (b) => (b.cache_control && typeof b.cache_control === "object" && b.cache_control.type === "ephemeral" ? { cache_control: { type: "ephemeral" } } : {});
|
|
341
|
+
|
|
342
|
+
/** A block of the Messages API, rebuilt field by field: a type or a field this agent does not know is refused, never forwarded. */
|
|
343
|
+
function maskBlock(b, ctx, acc, { allowTools }) {
|
|
344
|
+
if (!b || typeof b !== "object" || Array.isArray(b)) throw "bad_request";
|
|
345
|
+
if (b.type === "text" && typeof b.text === "string") return { type: "text", text: maskText(b.text, ctx, acc), ...cacheOf(b) };
|
|
346
|
+
if (allowTools && b.type === "tool_use" && TOOL_ID.test(String(b.id)) && TOOL_NAME.test(String(b.name)) && b.input && typeof b.input === "object" && !Array.isArray(b.input)) return { type: "tool_use", id: b.id, name: b.name, input: maskJson(b.input, ctx, acc) };
|
|
347
|
+
if (allowTools && b.type === "tool_result" && TOOL_ID.test(String(b.tool_use_id))) {
|
|
348
|
+
const content = typeof b.content === "string" ? maskText(b.content, ctx, acc)
|
|
349
|
+
: Array.isArray(b.content) ? b.content.map((x) => maskBlock(x, ctx, acc, { allowTools: false })) : b.content === undefined ? "" : (() => { throw "bad_request"; })();
|
|
350
|
+
return { type: "tool_result", tool_use_id: b.tool_use_id, content, ...(b.is_error === true ? { is_error: true } : {}) };
|
|
351
|
+
}
|
|
352
|
+
throw "bad_request";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function maskTools(tools, ctx, acc) {
|
|
356
|
+
if (!Array.isArray(tools) || tools.length > 64) throw "bad_request";
|
|
357
|
+
return tools.map((t) => {
|
|
358
|
+
if (!t || typeof t !== "object" || !TOOL_NAME.test(String(t.name)) || !t.input_schema || typeof t.input_schema !== "object" || Array.isArray(t.input_schema)) throw "bad_request";
|
|
359
|
+
if (t.description !== undefined && (typeof t.description !== "string" || byteLength(t.description) > 16 * 1024)) throw "bad_request";
|
|
360
|
+
if (byteLength(JSON.stringify(t.input_schema)) > 64 * 1024) throw "bad_request";
|
|
361
|
+
return { name: t.name, ...(t.description !== undefined ? { description: maskText(t.description, ctx, acc) } : {}), input_schema: maskJson(t.input_schema, ctx, acc), ...cacheOf(t) };
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function parseMessagesJson(body, maxBytes) {
|
|
366
|
+
const text = await readBounded(body, maxBytes);
|
|
367
|
+
let j;
|
|
368
|
+
try { j = JSON.parse(text); } catch { throw { code: "bad_response", retry: false }; }
|
|
369
|
+
if (!j || typeof j !== "object" || !Array.isArray(j.content) || j.content.some((b) => !b || typeof b !== "object" || typeof b.type !== "string")) throw { code: "bad_response", retry: false };
|
|
370
|
+
const u = j.usage && typeof j.usage === "object" ? j.usage : null;
|
|
371
|
+
const n = (x) => (Number.isSafeInteger(x) && x >= 0 ? x : 0);
|
|
372
|
+
const input = u ? n(u.input_tokens) + n(u.cache_creation_input_tokens) + n(u.cache_read_input_tokens) : null;
|
|
373
|
+
const usage = u && Number.isSafeInteger(u.input_tokens) && Number.isSafeInteger(u.output_tokens) ? { promptTokens: input, completionTokens: n(u.output_tokens), estimated: false } : null;
|
|
374
|
+
const stop = typeof j.stop_reason === "string" ? j.stop_reason.slice(0, 32) : null;
|
|
375
|
+
const message = { id: typeof j.id === "string" ? j.id.slice(0, 128) : "msg_la", type: "message", role: "assistant", content: j.content, stop_reason: stop, stop_sequence: null,
|
|
376
|
+
usage: u ? { input_tokens: n(u.input_tokens), output_tokens: n(u.output_tokens), ...(Number.isSafeInteger(u.cache_creation_input_tokens) ? { cache_creation_input_tokens: u.cache_creation_input_tokens } : {}), ...(Number.isSafeInteger(u.cache_read_input_tokens) ? { cache_read_input_tokens: u.cache_read_input_tokens } : {}) } : { input_tokens: 0, output_tokens: 0 } };
|
|
377
|
+
return { text: j.content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text).join(""), usage, model: modelOf(j.model), finishReason: stop, message };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* F3-08c: one turn of the Anthropic Messages API (`POST <endpoint>/v1/messages`, what the packaged agent loop speaks, with tools), through the SAME gates
|
|
382
|
+
* as chat(): DLP enforce, key and alias, EVERY string the model will read masked (system, text, tool_use input, tool_result, tool descriptions), size and
|
|
383
|
+
* token budget, concurrency slot, timeouts and retries, codes-only errors. Not streaming. Blocks and fields outside the known set are refused.
|
|
384
|
+
* @param {object} o model (alias), system (string | text blocks), messages [{role:user|assistant, content:string|blocks}], tools?, toolChoice?, maxTokens?, temperature?, signal?, job?, tokensLeft?
|
|
385
|
+
* @returns {Promise<{text:string, message:object, usage, costUsd, model, finishReason, attempts}>} `message` = the response in the Messages API shape (local use only)
|
|
386
|
+
*/
|
|
387
|
+
async function messages(o = {}) {
|
|
388
|
+
stats.calls++;
|
|
389
|
+
const ctx = { t0: now(), model: typeof o.model === "string" && MODEL.test(o.model) ? o.model : "invalid", jobShort: o.job?.jobId ? short(o.job.jobId) : undefined, promptBytes: 0, redactions: {}, stream: false, delivered: false, deadline: now() + config.totalMs };
|
|
390
|
+
const msgs = o.messages;
|
|
391
|
+
if (typeof o.model !== "string" || !MODEL.test(o.model) || !Array.isArray(msgs) || msgs.length < 1 || msgs.length > config.maxApiMessages
|
|
392
|
+
|| !msgs.every((m) => m && typeof m === "object" && (m.role === "user" || m.role === "assistant") && (typeof m.content === "string" || (Array.isArray(m.content) && m.content.length >= 1 && m.content.length <= 256)))) return fail("bad_request", ctx);
|
|
393
|
+
if (o.temperature !== undefined && !(Number.isFinite(o.temperature) && o.temperature >= 0 && o.temperature <= 1)) return fail("bad_request", ctx);
|
|
394
|
+
if (o.system !== undefined && typeof o.system !== "string" && !(Array.isArray(o.system) && o.system.length <= 64)) return fail("bad_request", ctx);
|
|
395
|
+
if (o.toolChoice !== undefined && !(o.toolChoice && ((["auto", "any", "none"].includes(o.toolChoice.type)) || (o.toolChoice.type === "tool" && TOOL_NAME.test(String(o.toolChoice.name)))))) return fail("bad_request", ctx);
|
|
396
|
+
if (o.signal?.aborted) return fail("cancelled", ctx);
|
|
397
|
+
if (dlpConfigFromEnv(env).mode !== "enforce") return fail("dlp_not_enforced", ctx);
|
|
398
|
+
if (!holder.held) return fail("no_key", ctx);
|
|
399
|
+
const d = holder.describe();
|
|
400
|
+
if (!d.held) return fail("key_expired", ctx);
|
|
401
|
+
if (!d.models.includes(o.model)) return fail("model_not_allowed", ctx);
|
|
402
|
+
const acc = { bytes: 0, nodes: 0 };
|
|
403
|
+
let system;
|
|
404
|
+
let masked;
|
|
405
|
+
let tools;
|
|
406
|
+
try {
|
|
407
|
+
if (typeof o.system === "string") system = maskText(o.system, ctx, acc);
|
|
408
|
+
else if (Array.isArray(o.system)) system = o.system.map((b) => maskBlock(b, ctx, acc, { allowTools: false }));
|
|
409
|
+
masked = msgs.map((m) => ({ role: m.role, content: typeof m.content === "string" ? maskText(m.content, ctx, acc) : m.content.map((b) => maskBlock(b, ctx, acc, { allowTools: true })) }));
|
|
410
|
+
if (o.tools !== undefined) tools = maskTools(o.tools, ctx, acc);
|
|
411
|
+
} catch (code) {
|
|
412
|
+
return fail(typeof code === "string" ? code : "bad_request", ctx);
|
|
413
|
+
}
|
|
414
|
+
ctx.promptBytes = acc.bytes;
|
|
415
|
+
let maxTokens = Math.min(Number.isSafeInteger(o.maxTokens) && o.maxTokens > 0 ? o.maxTokens : config.maxOutputTokens, config.maxOutputTokens);
|
|
416
|
+
if (o.tokensLeft !== undefined) {
|
|
417
|
+
const room = o.tokensLeft - estimateTokens(ctx.promptBytes);
|
|
418
|
+
if (!(room >= 1)) return fail("budget_exceeded", ctx);
|
|
419
|
+
maxTokens = Math.min(maxTokens, room);
|
|
420
|
+
}
|
|
421
|
+
return dispatch(o, ctx, { path: config.messagesPath, parseJson: parseMessagesJson, api: "messages" }, (tags) => JSON.stringify({
|
|
422
|
+
model: o.model, max_tokens: maxTokens, messages: masked,
|
|
423
|
+
...(system !== undefined ? { system } : {}),
|
|
424
|
+
...(tools ? { tools } : {}),
|
|
425
|
+
...(o.toolChoice ? { tool_choice: o.toolChoice } : {}),
|
|
426
|
+
...(o.temperature !== undefined ? { temperature: o.temperature } : {}),
|
|
427
|
+
...(tags ? { metadata: tags } : {}),
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { chat, messages, stats, audit: auditor, get active() { return active; } };
|
|
432
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.js - LOCAL_AGENT_LLM_CLIENT flag and limits of the Local Agent's LLM client (F3-10).
|
|
3
|
+
*
|
|
4
|
+
* false (default) there is NO LLM client: `link.llm.client` does not exist, the key holder (F3-09) only holds the key.
|
|
5
|
+
* true `link.llm.client.chat()` calls the AI gateway (the endpoint the control plane delivered with the key, OpenAI-compatible
|
|
6
|
+
* chat/completions) with the virtual key held by the llm-key holder. It needs, all three:
|
|
7
|
+
* LOCAL_AGENT_PROTOCOL_V2=true (a v2 session: nobody delivers a key without it)
|
|
8
|
+
* LOCAL_AGENT_LLM_KEY=true (the holder: the client has no key of its own)
|
|
9
|
+
* LOCAL_AGENT_DLP_MODE=enforce (prompts leave this machine only masked; there is no opt-out)
|
|
10
|
+
* runtime.js refuses (and says so) when one is missing.
|
|
11
|
+
*
|
|
12
|
+
* Unset/empty/unrecognised = false (an unknown value must never open a way out for client content).
|
|
13
|
+
* Every number is clamped: a bad value falls back to the default, never to "unlimited".
|
|
14
|
+
*/
|
|
15
|
+
export const LLM_CLIENT_FLAG = "LOCAL_AGENT_LLM_CLIENT";
|
|
16
|
+
|
|
17
|
+
const TRUE = new Set(["true", "1", "yes", "on"]);
|
|
18
|
+
const FALSE = new Set(["", "false", "0", "no", "off"]);
|
|
19
|
+
const KIB = 1024;
|
|
20
|
+
const MIB = 1024 * KIB;
|
|
21
|
+
|
|
22
|
+
function int(raw, def, min, max) {
|
|
23
|
+
const n = Number.parseInt(String(raw ?? ""), 10);
|
|
24
|
+
return Number.isFinite(n) && n >= min && n <= max ? n : def;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const bool = (raw, def) => {
|
|
28
|
+
const v = String(raw ?? "").trim().toLowerCase();
|
|
29
|
+
if (TRUE.has(v)) return true;
|
|
30
|
+
if (v !== "" && FALSE.has(v)) return false;
|
|
31
|
+
return def;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const PATH = /^\/(?!\/)[A-Za-z0-9._~/-]{1,200}$/;
|
|
35
|
+
|
|
36
|
+
export function resolveLlmClientConfig(env = process.env, warn = console.warn) {
|
|
37
|
+
const raw = String(env[LLM_CLIENT_FLAG] ?? "").trim().toLowerCase();
|
|
38
|
+
if (!TRUE.has(raw)) {
|
|
39
|
+
if (!FALSE.has(raw)) warn(`[la-llm] unrecognised ${LLM_CLIENT_FLAG} value; the LLM client stays off`);
|
|
40
|
+
return { enabled: false };
|
|
41
|
+
}
|
|
42
|
+
let path = String(env.LOCAL_AGENT_LLM_PATH ?? "").trim() || "/v1/chat/completions";
|
|
43
|
+
if (!PATH.test(path) || path.split("/").includes("..")) { warn("[la-llm] LOCAL_AGENT_LLM_PATH is not a plain absolute path; using /v1/chat/completions"); path = "/v1/chat/completions"; }
|
|
44
|
+
return {
|
|
45
|
+
enabled: true,
|
|
46
|
+
path, // appended to the endpoint the control plane gave (the holder never shows it); set it to /chat/completions when that endpoint already ends in /v1
|
|
47
|
+
messagesPath: PATH.test(String(env.LOCAL_AGENT_LLM_MESSAGES_PATH ?? "").trim()) ? String(env.LOCAL_AGENT_LLM_MESSAGES_PATH).trim() : "/v1/messages", // F3-08c: Anthropic Messages API (the packaged agent loop); set /messages when the endpoint of the key already ends in /v1
|
|
48
|
+
maxApiMessages: int(env.LOCAL_AGENT_LLM_MAX_API_MESSAGES, 256, 1, 1024), // messages per turn of the Messages API (an agent loop re-sends its whole history)
|
|
49
|
+
timeoutMs: int(env.LOCAL_AGENT_LLM_TIMEOUT_MS, 60_000, 1_000, 300_000), // one attempt: headers + body (streaming: until the headers)
|
|
50
|
+
streamIdleMs: int(env.LOCAL_AGENT_LLM_STREAM_IDLE_MS, 30_000, 1_000, 120_000), // streaming: silence between two chunks
|
|
51
|
+
totalMs: int(env.LOCAL_AGENT_LLM_TOTAL_MS, 180_000, 1_000, 900_000), // the whole call, retries included
|
|
52
|
+
maxRetries: int(env.LOCAL_AGENT_LLM_MAX_RETRIES, 2, 0, 5),
|
|
53
|
+
backoffMs: int(env.LOCAL_AGENT_LLM_BACKOFF_MS, 500, 0, 10_000),
|
|
54
|
+
maxBackoffMs: 10_000,
|
|
55
|
+
maxPromptBytes: int(env.LOCAL_AGENT_LLM_MAX_PROMPT_BYTES, 256 * KIB, KIB, 2 * MIB), // all messages, after masking
|
|
56
|
+
maxOutputTokens: int(env.LOCAL_AGENT_LLM_MAX_OUTPUT_TOKENS, 4_096, 1, 32_768),
|
|
57
|
+
maxResponseBytes: int(env.LOCAL_AGENT_LLM_MAX_RESPONSE_BYTES, 1 * MIB, 4 * KIB, 8 * MIB),
|
|
58
|
+
maxMessages: 64,
|
|
59
|
+
maxConcurrent: int(env.LOCAL_AGENT_LLM_MAX_CONCURRENT, 4, 1, 16),
|
|
60
|
+
tags: bool(env.LOCAL_AGENT_LLM_TAGS, true), // request metadata.tags (tenant, agent, job); ids only, never content
|
|
61
|
+
auditFile: String(env.LOCAL_AGENT_LLM_AUDIT_FILE ?? "").trim() || null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* install.js - puts the LLM client on the key holder (F3-10). Called by runtime.js only when LOCAL_AGENT_LLM_CLIENT=true, LOCAL_AGENT_LLM_KEY=true
|
|
3
|
+
* (link.llm exists) and LOCAL_AGENT_DLP_MODE=enforce. Hooks `link.llm.request` (the holder's one way out for the key) into the client:
|
|
4
|
+
* the client has no other way to reach the AI gateway. Returns the client; also at `link.llm.client`.
|
|
5
|
+
*/
|
|
6
|
+
import { dlpConfigFromEnv } from "../../dlp/index.js";
|
|
7
|
+
import { createLlmClient } from "./client.js";
|
|
8
|
+
|
|
9
|
+
export function installLlmClient({ link, config, tenantId, agentId, env = process.env, now = Date.now, fetchImpl }) {
|
|
10
|
+
if (!link.llm || !link.llm.holder) throw new Error("the LLM client needs the llm-key holder (LOCAL_AGENT_LLM_KEY=true)");
|
|
11
|
+
// F3-07b/F3-10, defence in depth: runtime.js already refuses; nobody can install the client without DLP enforce
|
|
12
|
+
if (dlpConfigFromEnv(env).mode !== "enforce") throw new Error("the LLM client needs LOCAL_AGENT_DLP_MODE=enforce");
|
|
13
|
+
const client = createLlmClient({ holder: { request: link.llm.request, describe: () => link.llm.holder.describe(), get held() { return link.llm.holder.held; } },
|
|
14
|
+
config, tenantId: String(tenantId).toLowerCase(), agentId: String(agentId).toLowerCase(), env, now, fetchImpl });
|
|
15
|
+
link.llm.client = client;
|
|
16
|
+
return client;
|
|
17
|
+
}
|
|
@@ -150,6 +150,16 @@ export function createLlmKeyHolder({ file = null, workspaceRoots = () => [], now
|
|
|
150
150
|
return true;
|
|
151
151
|
} catch { return false; }
|
|
152
152
|
},
|
|
153
|
+
/**
|
|
154
|
+
* F3-10: does this reference (`job.assign.llm.keyRef`) name the key held right now? Compares in constant time and answers a boolean only:
|
|
155
|
+
* the id never leaves the holder. A reference is the key id or its first 8..64 hex characters (the `key_short` the control plane logs).
|
|
156
|
+
*/
|
|
157
|
+
matches(ref) {
|
|
158
|
+
if (!active() || typeof ref !== "string" || !/^[0-9a-f]{8,64}$/.test(ref)) return false;
|
|
159
|
+
const a = Buffer.from(held.keyId.slice(0, ref.length));
|
|
160
|
+
const b = Buffer.from(ref);
|
|
161
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
162
|
+
},
|
|
153
163
|
/** Metadata only. */
|
|
154
164
|
describe() { return active() ? { held: true, models: [...held.models], expiresAt: held.expiresAt } : { held: false }; },
|
|
155
165
|
get held() { return active(); },
|