@zenithfoundry/slm-gate 1.2.1

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.
Files changed (106) hide show
  1. package/.env.example +669 -0
  2. package/LICENSE +21 -0
  3. package/README.md +317 -0
  4. package/configs/antigravity/.env.16gb.example +674 -0
  5. package/configs/antigravity/.env.24gb.example +674 -0
  6. package/configs/antigravity/.env.32gb.example +674 -0
  7. package/configs/antigravity/README.md +109 -0
  8. package/configs/claude-code/.env.16gb.example +674 -0
  9. package/configs/claude-code/.env.24gb.example +674 -0
  10. package/configs/claude-code/.env.32gb.example +674 -0
  11. package/configs/claude-code/README.md +52 -0
  12. package/configs/claude-desktop/.env.16gb.example +674 -0
  13. package/configs/claude-desktop/.env.24gb.example +674 -0
  14. package/configs/claude-desktop/.env.32gb.example +674 -0
  15. package/configs/claude-desktop/README.md +37 -0
  16. package/configs/cline-continue-opencode/.env.16gb.example +674 -0
  17. package/configs/cline-continue-opencode/.env.24gb.example +674 -0
  18. package/configs/cline-continue-opencode/.env.32gb.example +674 -0
  19. package/configs/cline-continue-opencode/README.md +34 -0
  20. package/configs/cursor/.env.16gb.example +674 -0
  21. package/configs/cursor/.env.24gb.example +674 -0
  22. package/configs/cursor/.env.32gb.example +674 -0
  23. package/configs/cursor/README.md +26 -0
  24. package/configs/generic-http/.env.16gb.example +674 -0
  25. package/configs/generic-http/.env.24gb.example +674 -0
  26. package/configs/generic-http/.env.32gb.example +674 -0
  27. package/configs/generic-http/README.md +20 -0
  28. package/configs/generic-stdio/.env.16gb.example +674 -0
  29. package/configs/generic-stdio/.env.24gb.example +674 -0
  30. package/configs/generic-stdio/.env.32gb.example +674 -0
  31. package/configs/generic-stdio/README.md +24 -0
  32. package/configs/preserve/README.md +26 -0
  33. package/configs/preserve/tls.json +61 -0
  34. package/dist/adapters/tech-lead-stack.js +38 -0
  35. package/dist/cache/index.js +173 -0
  36. package/dist/cli.js +256 -0
  37. package/dist/config.js +255 -0
  38. package/dist/dashboard/data.js +149 -0
  39. package/dist/dashboard/export.js +42 -0
  40. package/dist/dashboard/serve.js +63 -0
  41. package/dist/doctor.js +338 -0
  42. package/dist/hardware.js +126 -0
  43. package/dist/home-dir.js +39 -0
  44. package/dist/ledger/flush-lifecycle.js +50 -0
  45. package/dist/ledger/index.js +946 -0
  46. package/dist/ledger/report.js +69 -0
  47. package/dist/ledger/setup-dashboard.js +456 -0
  48. package/dist/ledger/smoke.js +37 -0
  49. package/dist/ledger/sync-config.js +177 -0
  50. package/dist/ledger/sync.js +307 -0
  51. package/dist/ledger/verify.js +185 -0
  52. package/dist/ledger/wipe-langfuse.js +130 -0
  53. package/dist/llm-gate/distill.js +239 -0
  54. package/dist/llm-gate/formats/anthropic.js +185 -0
  55. package/dist/llm-gate/formats/chat-completions.js +103 -0
  56. package/dist/llm-gate/formats/contract.js +29 -0
  57. package/dist/llm-gate/formats/gemini.js +84 -0
  58. package/dist/llm-gate/formats/internal.js +1 -0
  59. package/dist/llm-gate/formats/openai.js +77 -0
  60. package/dist/llm-gate/formats/responses.js +146 -0
  61. package/dist/llm-gate/forward.js +150 -0
  62. package/dist/llm-gate/index.js +40 -0
  63. package/dist/llm-gate/local-first.js +217 -0
  64. package/dist/llm-gate/pipeline.js +267 -0
  65. package/dist/llm-gate/server.js +289 -0
  66. package/dist/mcp-gate/ground.js +64 -0
  67. package/dist/mcp-gate/index.js +57 -0
  68. package/dist/mcp-gate/pipeline.js +252 -0
  69. package/dist/mcp-gate/server.js +302 -0
  70. package/dist/mcp-gate/tool-names.js +57 -0
  71. package/dist/models/check.js +26 -0
  72. package/dist/models/footprint.js +137 -0
  73. package/dist/models/helpers.js +91 -0
  74. package/dist/models/index.js +5 -0
  75. package/dist/models/reasoning.js +91 -0
  76. package/dist/models/roles.js +9 -0
  77. package/dist/models/slm.js +243 -0
  78. package/dist/models/types.js +1 -0
  79. package/dist/pricing/index.js +115 -0
  80. package/dist/pricing/plans.js +54 -0
  81. package/dist/pricing/providers.js +172 -0
  82. package/dist/resolver/index.js +277 -0
  83. package/dist/resolver/types.js +1 -0
  84. package/dist/setup/claim.js +41 -0
  85. package/dist/setup/gate-command.js +41 -0
  86. package/dist/setup/init.js +92 -0
  87. package/dist/setup/local-models.js +123 -0
  88. package/dist/setup/model-gate.js +220 -0
  89. package/dist/setup/notify.js +45 -0
  90. package/dist/setup/ollama-install.js +53 -0
  91. package/dist/setup/parent-watch.js +84 -0
  92. package/dist/setup/required-models.js +20 -0
  93. package/dist/setup/startup.js +132 -0
  94. package/dist/setup/tool-settings.js +101 -0
  95. package/dist/utils/backoff.js +47 -0
  96. package/dist/utils/compression.js +145 -0
  97. package/dist/utils/constants.js +22 -0
  98. package/dist/utils/duration.js +43 -0
  99. package/dist/utils/elision.js +556 -0
  100. package/dist/utils/embedding.js +32 -0
  101. package/dist/utils/entry-point.js +23 -0
  102. package/dist/utils/local-only.js +82 -0
  103. package/dist/utils/preserve-patterns.js +115 -0
  104. package/dist/utils/safety.js +30 -0
  105. package/dist/verifier/index.js +67 -0
  106. package/package.json +121 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @fileoverview Deletes slm-gate's own traces from the configured Langfuse project.
3
+ *
4
+ * Other programs may write to the same project, so only traces tagged SLM_GATE_SOURCE_TAG
5
+ * are deleted, and only the score configs the gate registers are archived — never the whole
6
+ * project. Traces written before the tag existed are not matched: `pnpm run ledger:sync`
7
+ * resends them with it.
8
+ *
9
+ * Deleting a trace also deletes its scores and observations (Langfuse's data-deletion docs),
10
+ * so scores are never deleted on their own. The previous script deleted every score first
11
+ * and every trace second; a rate limit between the two passes left the project half wiped.
12
+ *
13
+ * Resumable by construction: each run lists the tagged traces still present, so a run
14
+ * stopped by a quota is continued by running it again after the reset time it prints.
15
+ *
16
+ * Usage:
17
+ * pnpm run langfuse:wipe [--dry-run]
18
+ */
19
+ import { setTimeout } from 'node:timers/promises';
20
+ import { CONFIG, requireKeys } from '../config.js';
21
+ import { waitWithBackoff } from '../utils/backoff.js';
22
+ import { SLM_GATE_SOURCE_TAG } from './index.js';
23
+ import { SCORE_CONFIGS } from './sync-config.js';
24
+ /** Hobby projects allow 30 requests a minute; one every 2.1 s stays under it. */
25
+ const REQUEST_SPACING_MS = 2100;
26
+ const MAX_RETRIES = 3;
27
+ /** A 429 asking to wait longer than this is a quota (e.g. daily deletes), not a burst: stop and report. */
28
+ const MAX_RETRY_WAIT_S = 120;
29
+ const DELETE_CHUNK = 100;
30
+ /** Seconds a 429/5xx asks us to wait, from the Retry-After header or Langfuse's error body. */
31
+ async function retryAfterSeconds(res) {
32
+ const header = Number(res.headers.get('retry-after'));
33
+ if (Number.isFinite(header) && header > 0)
34
+ return header;
35
+ const body = await res.clone().json().catch(() => null);
36
+ return typeof body?.details?.retryAfterSeconds === 'number' ? body.details.retryAfterSeconds : null;
37
+ }
38
+ /** One paced request, retried on 429 and 5xx unless the wait asked for is a quota reset. */
39
+ async function send(params) {
40
+ const baseUrl = CONFIG.LANGFUSE_HOST.replace(/\/$/, '');
41
+ const auth = `Basic ${Buffer.from(`${CONFIG.LANGFUSE_PUBLIC_KEY}:${CONFIG.LANGFUSE_SECRET_KEY}`).toString('base64')}`;
42
+ for (let attempt = 0;; attempt++) {
43
+ await setTimeout(REQUEST_SPACING_MS);
44
+ const res = await fetch(`${baseUrl}${params.path}`, {
45
+ ...params.init,
46
+ headers: { Authorization: auth, 'Content-Type': 'application/json' },
47
+ });
48
+ const transient = res.status === 429 || res.status >= 500;
49
+ if (!transient || attempt >= MAX_RETRIES - 1)
50
+ return res;
51
+ const wait = await retryAfterSeconds(res);
52
+ if (wait !== null && wait > MAX_RETRY_WAIT_S)
53
+ return res;
54
+ await waitWithBackoff(attempt, MAX_RETRIES, `${params.label}: HTTP ${res.status}`, wait === null ? null : String(wait), 'wipe');
55
+ }
56
+ }
57
+ /** Throws with the status and body, so a failed request stops the run instead of being skipped. */
58
+ async function failure(res, label) {
59
+ const text = await res.text().catch(() => '');
60
+ if (res.status !== 429)
61
+ return new Error(`${label} failed (${res.status}): ${text.slice(0, 300)}`);
62
+ let details = {};
63
+ try {
64
+ details = JSON.parse(text).details ?? {};
65
+ }
66
+ catch { /* not JSON */ }
67
+ const resetAt = details.resetAt ? new Date(details.resetAt).toLocaleString() : 'unknown';
68
+ return new Error(`${label} was rate limited (remaining ${details.remaining ?? '?'} / ${details.limit ?? '?'}). ` +
69
+ `Quota resets at ${resetAt}. Run this command again after that to continue.`);
70
+ }
71
+ /** Every item of a paginated Langfuse list. */
72
+ async function listAll(params) {
73
+ const items = [];
74
+ for (let page = 1;; page++) {
75
+ const res = await send({ path: `${params.path}${params.path.includes('?') ? '&' : '?'}page=${page}&limit=100`, label: params.label });
76
+ if (!res.ok)
77
+ throw await failure(res, params.label);
78
+ const body = await res.json();
79
+ items.push(...(body.data ?? []));
80
+ if ((body.data ?? []).length === 0 || page >= (body.meta?.totalPages ?? 1))
81
+ return items;
82
+ }
83
+ }
84
+ async function wipeLangfuse(options) {
85
+ requireKeys(['LANGFUSE_PUBLIC_KEY', 'LANGFUSE_SECRET_KEY', 'LANGFUSE_HOST']);
86
+ console.log('=== SLM Gate: delete the gate\'s traces from Langfuse ===\n');
87
+ console.log(`Target host : ${CONFIG.LANGFUSE_HOST}`);
88
+ console.log(`Matching : traces tagged ${SLM_GATE_SOURCE_TAG}; other writers' traces are left alone`);
89
+ console.log(`Mode : ${options.dryRun ? 'DRY-RUN (nothing is deleted)' : 'DELETE'}\n`);
90
+ // The trace list is Langfuse's only way to find traces by tag. It is deprecated on Cloud
91
+ // (removal 2026-11-16) and lags live data by about 10 minutes, so traces written in the
92
+ // last few minutes may need a second run.
93
+ const traces = await listAll({
94
+ path: `/api/public/traces?tags=${encodeURIComponent(SLM_GATE_SOURCE_TAG)}`,
95
+ label: 'list gate traces',
96
+ });
97
+ const traceIds = traces.map(t => t.id);
98
+ console.log(`Found ${traceIds.length} gate trace(s). Their scores and observations are deleted with them.`);
99
+ const gateConfigNames = new Set(SCORE_CONFIGS.map(c => c.name));
100
+ const configs = (await listAll({ path: '/api/public/score-configs', label: 'list score configs' }))
101
+ .filter(c => gateConfigNames.has(c.name) && !c.isArchived);
102
+ console.log(`Found ${configs.length} active gate score config(s) to archive.\n`);
103
+ if (options.dryRun) {
104
+ console.log('Dry run: nothing was deleted.');
105
+ return;
106
+ }
107
+ let deleted = 0;
108
+ for (let i = 0; i < traceIds.length; i += DELETE_CHUNK) {
109
+ const chunk = traceIds.slice(i, i + DELETE_CHUNK);
110
+ const res = await send({ path: '/api/public/traces', init: { method: 'DELETE', body: JSON.stringify({ traceIds: chunk }) }, label: 'delete traces' });
111
+ if (!res.ok) {
112
+ console.error(`\nDeleted ${deleted} of ${traceIds.length} trace(s) before stopping.`);
113
+ throw await failure(res, 'delete traces');
114
+ }
115
+ deleted += chunk.length;
116
+ console.log(` Deleted ${deleted} / ${traceIds.length}`);
117
+ }
118
+ for (const config of configs) {
119
+ const res = await send({ path: `/api/public/score-configs/${config.id}`, init: { method: 'PATCH', body: JSON.stringify({ isArchived: true }) }, label: `archive score config ${config.name}` });
120
+ if (!res.ok)
121
+ throw await failure(res, `archive score config ${config.name}`);
122
+ console.log(` Archived score config ${config.name}`);
123
+ }
124
+ console.log(`\n✓ Deleted ${deleted} gate trace(s) and archived ${configs.length} gate score config(s).`);
125
+ console.log('Langfuse can take ~10 minutes to stop listing deleted traces; check with `pnpm run ledger:verify`.');
126
+ }
127
+ wipeLangfuse({ dryRun: process.argv.includes('--dry-run') }).catch((err) => {
128
+ console.error('\n⛔', err instanceof Error ? err.message : err);
129
+ process.exit(1);
130
+ });
@@ -0,0 +1,239 @@
1
+ /**
2
+ * @fileoverview Step B of the model gate: distil the large command, search and listing tool results of
3
+ * one request before it leaves the machine, and change nothing else.
4
+ *
5
+ * Byte stability is the point. Coding tools resend the whole history every turn, providers cache that
6
+ * history, and Anthropic rejects a request whose earlier messages differ from what produced earlier
7
+ * thinking. So each tool result is decided once per conversation — distilled text or original — and
8
+ * that decision is resent from then on:
9
+ * - Only results the provider has never seen (the newest turn) are ever distilled. A history result
10
+ * with no stored decision was necessarily sent as the original, so it stays the original.
11
+ * - Distilled text is sent only after its decision is stored. If storing fails the original is sent,
12
+ * so "no stored decision" can only ever mean "the original was sent".
13
+ * - Past the time budget, or on any error, the original is the decision.
14
+ * - The decision key holds only what cannot change within a conversation (see decisionKey).
15
+ * File reads are never distilled because edits need their exact text; that includes shell commands
16
+ * that read files, which is how Codex reads them.
17
+ */
18
+ import crypto from 'node:crypto';
19
+ import { CONFIG } from '../config.js';
20
+ import { getDistillDecision, recordDistillDecision } from '../ledger/index.js';
21
+ import { compressNarrativeRun } from '../models/reasoning.js';
22
+ import { SLM } from '../models/slm.js';
23
+ import { distillToolResult, estimateTokens, rewriteElisionHint } from '../utils/elision.js';
24
+ import { buildPreserveList } from '../utils/preserve-patterns.js';
25
+ // Lowercased function names of the tools whose output is distilled. Anything else — file reads, web
26
+ // fetches, sub-agents, MCP tools, edits — passes unchanged.
27
+ const KIND_BY_TOOL = new Map([
28
+ ...['bash', 'run_shell_command', 'shell', 'exec_command', 'local_shell', 'execute_command', 'run_command'].map(name => [name, 'command']),
29
+ ...['grep', 'grep_search', 'search_file_content', 'search_files'].map(name => [name, 'search']),
30
+ ...['glob', 'list_directory', 'ls', 'list_files', 'list_dir', 'list'].map(name => [name, 'listing']),
31
+ ]);
32
+ // distillToolResult picks its rules by a substring of the tool name: these fire the log rule (errors
33
+ // with context plus the last 50 lines) and the top-50-lines rule. The real name follows a colon.
34
+ const ENGINE_RULE = { command: 'run_command', search: 'grep_search', listing: 'list_dir' };
35
+ const FILE_READERS = new Set(['cat', 'bat', 'less', 'more', 'head', 'tail', 'nl']);
36
+ // Words that run the program after them.
37
+ const COMMAND_PREFIXES = new Set(['env', 'sudo', 'command', 'exec', 'time', 'nice', 'xargs']);
38
+ class DeadlineExceeded extends Error {
39
+ }
40
+ let slm;
41
+ let preserveList;
42
+ const inFlight = new Map();
43
+ function parsedArgs(callArgs) {
44
+ if (typeof callArgs !== 'string')
45
+ return callArgs;
46
+ try {
47
+ return JSON.parse(callArgs);
48
+ }
49
+ catch {
50
+ return { command: callArgs };
51
+ }
52
+ }
53
+ /** The shell command a command tool ran: `command` / `cmd` as a string or an argv array. */
54
+ export function commandOf(callArgs) {
55
+ const args = parsedArgs(callArgs);
56
+ const command = args?.command ?? args?.cmd;
57
+ if (Array.isArray(command))
58
+ return command.map(String).join(' ');
59
+ return typeof command === 'string' ? command : '';
60
+ }
61
+ /**
62
+ * True when any step of the command prints a file: cat, bat, less, more, head, tail, nl, or sed with
63
+ * -n (also -ne, -En), after `bash -lc` / `sh -c` wrappers, `NAME=value` assignments and the prefixes
64
+ * env, sudo, command, exec, time, nice, xargs. Pipes into head/tail count too, which keeps some
65
+ * listings whole (the safe direction). Not detected, so still distilled: awk, python -c,
66
+ * `git show rev:file`, `find -exec cat`.
67
+ */
68
+ export function isFileReadCommand(command) {
69
+ return command.split(/&&|\|\||;|\|/).some(segment => {
70
+ let words = segment.trim().split(/\s+/).map(word => word.replace(/^['"]+|['"]+$/g, '')).filter(Boolean);
71
+ for (;;) {
72
+ if (words.length > 2 && /^(?:ba|z)?sh$/.test(words[0]) && /^-\w*c$/.test(words[1]))
73
+ words = words.slice(2);
74
+ else if (words.length > 1 && /^[A-Za-z_]\w*=/.test(words[0]))
75
+ words = words.slice(1);
76
+ else
77
+ break;
78
+ }
79
+ // A prefix may carry its own flags and values before the real program (`nice -n 10 cat`,
80
+ // `xargs -I {} cat {}`), so any reader word after it counts — the safe direction.
81
+ const programs = COMMAND_PREFIXES.has(words[0]) ? words.slice(1) : words.slice(0, 1);
82
+ return programs.some(word => {
83
+ const program = word.split('/').pop() ?? '';
84
+ return FILE_READERS.has(program) || (program === 'sed' && words.some(flag => /^-[A-Za-z]*n[A-Za-z]*$/.test(flag)));
85
+ });
86
+ });
87
+ }
88
+ function distillableKind(result) {
89
+ const kind = KIND_BY_TOOL.get(result.toolName.toLowerCase());
90
+ if (!kind)
91
+ return null;
92
+ if (kind === 'command' && isFileReadCommand(commandOf(result.callArgs)))
93
+ return null;
94
+ return kind;
95
+ }
96
+ /**
97
+ * Identifies the conversation by its first user entry, which never changes while the conversation lasts
98
+ * (the history only grows). Two sessions that meet the same tool output must not share a decision: one
99
+ * of them may already have sent the original.
100
+ */
101
+ function conversationKey(body) {
102
+ const history = body.messages ?? body.contents ?? (Array.isArray(body.input) ? body.input : []);
103
+ const first = history.find(entry => entry?.role === 'user') ?? history[0] ?? null;
104
+ return crypto.createHash('sha256').update(JSON.stringify(first)).digest('hex');
105
+ }
106
+ /**
107
+ * Only what cannot change for a result within a conversation. Engine version, prompt version and marker
108
+ * wording are deliberately left out: a changed key would find no decision for history the provider has
109
+ * already seen distilled.
110
+ */
111
+ function decisionKey(params) {
112
+ const { conversation, result } = params;
113
+ return crypto.createHash('sha256').update(conversation).update('\0').update(result.toolName).update('\0').update(result.text).digest('hex');
114
+ }
115
+ function toolNames(tools) {
116
+ if (!Array.isArray(tools))
117
+ return [];
118
+ return tools
119
+ .flatMap((tool) => [tool?.name, tool?.function?.name, ...(Array.isArray(tool?.functionDeclarations) ? tool.functionDeclarations.map((f) => f?.name) : [])])
120
+ .filter((name) => typeof name === 'string');
121
+ }
122
+ const defaultEngine = async ({ text, toolName, callArgs, kind }) => {
123
+ const client = (slm ??= new SLM());
124
+ preserveList ??= buildPreserveList();
125
+ const compress = (run, task) => compressNarrativeRun({ slm: client, text: run, task });
126
+ // An empty task keeps the engine's own cache key the same on every turn.
127
+ return distillToolResult(compress, text, '', `${ENGINE_RULE[kind]}:${toolName}`, callArgs, await preserveList);
128
+ };
129
+ /** One distillation per key at a time: a retried request joins the one already running. */
130
+ function distilOnce(key, run) {
131
+ let pending = inFlight.get(key);
132
+ if (!pending) {
133
+ pending = run();
134
+ inFlight.set(key, pending);
135
+ const forget = () => { inFlight.delete(key); };
136
+ pending.then(forget, forget);
137
+ }
138
+ return pending;
139
+ }
140
+ function beforeDeadline(work, deadline) {
141
+ let timer;
142
+ const expiry = new Promise((_, reject) => {
143
+ timer = setTimeout(() => reject(new DeadlineExceeded()), Math.max(0, deadline - Date.now()));
144
+ });
145
+ return Promise.race([work, expiry]).finally(() => clearTimeout(timer));
146
+ }
147
+ /** Stores a decision and returns the text to send; distilled text is only sent once its decision is on disk. */
148
+ function persistThenSend(params) {
149
+ const { key, original, text, outcome } = params;
150
+ try {
151
+ return { text: recordDistillDecision({ key, text, outcome }).text, stored: true };
152
+ }
153
+ catch (err) {
154
+ console.error(`[llm-gate] distill decision not stored (${err instanceof Error ? err.message : String(err)}); sending the original`);
155
+ return { text: original, stored: false };
156
+ }
157
+ }
158
+ async function decide(params) {
159
+ const { result, kind, conversation, deadline, engine, expandHint } = params;
160
+ const key = decisionKey({ conversation, result });
161
+ let stored;
162
+ try {
163
+ stored = getDistillDecision(key);
164
+ }
165
+ catch (err) {
166
+ // Unknown whether a decision exists: send the original, record nothing.
167
+ console.error(`[llm-gate] distill decision not readable (${err instanceof Error ? err.message : String(err)}); sending the original`);
168
+ return { text: result.text, outcome: 'read_error' };
169
+ }
170
+ if (stored)
171
+ return { text: stored.text, outcome: 'reused' };
172
+ if (!result.newTurn) {
173
+ // The provider has already seen this text and no decision exists, so what it saw was the original.
174
+ const sent = persistThenSend({ key, original: result.text, text: result.text, outcome: 'history' });
175
+ return { text: sent.text, outcome: sent.stored ? 'history' : 'store_error' };
176
+ }
177
+ let text = result.text;
178
+ let outcome = 'distilled';
179
+ try {
180
+ const distilled = await beforeDeadline(distilOnce(key, () => engine({ text: result.text, toolName: result.toolName, callArgs: result.callArgs, kind })), deadline);
181
+ text = expandHint ? distilled : rewriteElisionHint(distilled);
182
+ }
183
+ catch (err) {
184
+ outcome = err instanceof DeadlineExceeded ? 'timeout' : 'error';
185
+ if (outcome === 'error')
186
+ console.error(`[llm-gate] distillation failed, sending the original: ${err instanceof Error ? err.message : String(err)}`);
187
+ }
188
+ const sent = persistThenSend({ key, original: result.text, text, outcome });
189
+ return { text: sent.text, outcome: sent.stored ? outcome : 'store_error' };
190
+ }
191
+ /**
192
+ * Distils the new large command, search and listing results of one request and resends every earlier
193
+ * decision unchanged.
194
+ *
195
+ * @param params.format The request's wire-format module
196
+ * @param params.body The parsed request body (not modified)
197
+ * @param params.engine Produces distilled text; defaults to distillToolResult with the local model
198
+ * @returns The body to send, or null when nothing changed, and what happened
199
+ */
200
+ export async function distilRequest(params) {
201
+ const { format, body, engine = defaultEngine } = params;
202
+ const results = format.listToolResults(body);
203
+ const candidates = results.flatMap(result => {
204
+ const kind = distillableKind(result);
205
+ return kind && estimateTokens(result.text) >= CONFIG.DISTILL_MIN_TOKENS ? [{ result, kind }] : [];
206
+ });
207
+ const stats = { toolResults: results.length, candidates: candidates.length, distilled: 0, reused: 0, keptOriginal: 0, timeouts: 0, errors: 0 };
208
+ if (candidates.length === 0)
209
+ return { body: null, stats };
210
+ const conversation = conversationKey(body);
211
+ const expandHint = toolNames(body.tools).some(name => name.endsWith('expand_elision'));
212
+ const deadline = Date.now() + CONFIG.DISTILL_BUDGET_MS;
213
+ const decisions = await Promise.all(candidates.map(({ result, kind }) => decide({ result, kind, conversation, deadline, engine, expandHint })));
214
+ let sendBody = body;
215
+ decisions.forEach((decision, i) => {
216
+ const { result } = candidates[i];
217
+ const changed = decision.text !== result.text;
218
+ if (decision.outcome === 'timeout')
219
+ stats.timeouts++;
220
+ else if (decision.outcome === 'error')
221
+ stats.errors++;
222
+ if (!changed)
223
+ stats.keptOriginal++;
224
+ else if (decision.outcome === 'reused')
225
+ stats.reused++;
226
+ else
227
+ stats.distilled++;
228
+ if (changed)
229
+ sendBody = format.replaceToolResultText({ body: sendBody, location: result.location, text: decision.text });
230
+ });
231
+ return { body: sendBody === body ? null : sendBody, stats };
232
+ }
233
+ /** Loads the local model in the background so the first distillation does not pay the ~9 s cold start. */
234
+ export function warmUpLocalModel() {
235
+ slm ??= new SLM();
236
+ slm.generateText(CONFIG.SLM_GATE_MODEL, [{ role: 'user', content: 'ok' }], 0, 1).catch(() => {
237
+ // Ollama down or model missing: each distillation then falls back to the original on its own.
238
+ });
239
+ }
@@ -0,0 +1,185 @@
1
+ import crypto from 'node:crypto';
2
+ import { typedText } from './contract.js';
3
+ /**
4
+ * Parses an incoming Anthropic-formatted messages request and translates it
5
+ * into the agnostic `InternalRequest` structure.
6
+ * Anthropic uses a top-level `system` field, which seamlessly maps to our internal structure.
7
+ *
8
+ * @param body The raw JSON body of an Anthropic API request
9
+ * @param modelFallback A default model to use if the request omits it
10
+ */
11
+ export function parseAnthropicRequest(body, modelFallback) {
12
+ const messages = [];
13
+ let system;
14
+ if (body.system) {
15
+ if (typeof body.system === 'string') {
16
+ system = body.system;
17
+ }
18
+ else if (Array.isArray(body.system)) {
19
+ system = body.system.map((b) => b.text || '').join('\n');
20
+ }
21
+ }
22
+ for (const m of (body.messages || [])) {
23
+ let content = '';
24
+ if (typeof m.content === 'string') {
25
+ content = m.content;
26
+ }
27
+ else if (Array.isArray(m.content)) {
28
+ content = m.content.map((b) => b.text || JSON.stringify(b)).join('\n');
29
+ }
30
+ messages.push({ role: m.role, content });
31
+ }
32
+ return {
33
+ system,
34
+ messages,
35
+ maxTokens: body.max_tokens,
36
+ stream: !!body.stream,
37
+ tools: body.tools, // If Anthropic native tools are sent
38
+ model: body.model || modelFallback
39
+ };
40
+ }
41
+ export function buildAnthropicRequest(internal) {
42
+ // Strip out any internal system messages from the messages array
43
+ const cleanMessages = internal.messages.filter(m => m.role !== 'system');
44
+ const req = {
45
+ model: internal.model,
46
+ messages: cleanMessages.map(m => ({ role: m.role === 'tool' ? 'user' : m.role, content: m.content })),
47
+ max_tokens: internal.maxTokens || 4096, // REQUIRED for Anthropic
48
+ };
49
+ // 1. Breakpoint on system block
50
+ if (internal.system) {
51
+ req.system = [{ type: 'text', text: internal.system, cache_control: { type: 'ephemeral' } }];
52
+ }
53
+ // 2. Breakpoint on last tool
54
+ if (internal.tools && internal.tools.length > 0) {
55
+ req.tools = [...internal.tools];
56
+ req.tools[req.tools.length - 1] = {
57
+ ...req.tools[req.tools.length - 1],
58
+ cache_control: { type: 'ephemeral' }
59
+ };
60
+ }
61
+ // 3. Breakpoint on historical message (skip current turn)
62
+ if (req.messages.length >= 3) {
63
+ // A standard turn is user -> assistant -> user, so length - 3 skips the current uncompleted turn
64
+ const targetIdx = req.messages.length - 3;
65
+ req.messages[targetIdx].content = [
66
+ { type: 'text', text: req.messages[targetIdx].content, cache_control: { type: 'ephemeral' } }
67
+ ];
68
+ }
69
+ if (internal.stream) {
70
+ req.stream = true;
71
+ }
72
+ return req;
73
+ }
74
+ export function formatAnthropicStreamChunk(content, isFirst = false, isLast = false, usage = null) {
75
+ let chunks = '';
76
+ if (isFirst) {
77
+ chunks += `event: message_start\ndata: ${JSON.stringify({ type: 'message_start', message: { id: `msg_${Date.now()}`, type: 'message', role: 'assistant', model: 'local', content: [] } })}\n\n`;
78
+ chunks += `event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })}\n\n`;
79
+ }
80
+ if (content) {
81
+ chunks += `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: content } })}\n\n`;
82
+ }
83
+ if (isLast) {
84
+ chunks += `event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: 0 })}\n\n`;
85
+ chunks += `event: message_delta\ndata: ${JSON.stringify({ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage })}\n\n`;
86
+ chunks += `event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`;
87
+ }
88
+ return chunks;
89
+ }
90
+ export function listToolResults(body) {
91
+ const messages = Array.isArray(body?.messages) ? body.messages : [];
92
+ const calls = new Map();
93
+ let lastAssistant = -1;
94
+ messages.forEach((message, i) => {
95
+ if (message?.role !== 'assistant')
96
+ return;
97
+ lastAssistant = i;
98
+ if (!Array.isArray(message.content))
99
+ return;
100
+ for (const block of message.content) {
101
+ if (block?.type === 'tool_use' && typeof block.id === 'string')
102
+ calls.set(block.id, { name: String(block.name ?? ''), input: block.input });
103
+ }
104
+ });
105
+ const results = [];
106
+ messages.forEach((message, i) => {
107
+ if (message?.role !== 'user' || !Array.isArray(message.content))
108
+ return;
109
+ message.content.forEach((block, j) => {
110
+ if (block?.type !== 'tool_result')
111
+ return;
112
+ const call = calls.get(block.tool_use_id);
113
+ const found = { toolName: call?.name ?? '', callArgs: call?.input, newTurn: i > lastAssistant };
114
+ if (typeof block.content === 'string') {
115
+ results.push({ ...found, location: { message: i, block: j }, text: block.content });
116
+ }
117
+ else if (Array.isArray(block.content)) {
118
+ block.content.forEach((part, k) => {
119
+ if (part?.type === 'text' && typeof part.text === 'string') {
120
+ results.push({ ...found, location: { message: i, block: j, part: k }, text: part.text });
121
+ }
122
+ });
123
+ }
124
+ });
125
+ });
126
+ return results;
127
+ }
128
+ export function replaceToolResultText(params) {
129
+ const { body, location: { message, block, part }, text } = params;
130
+ const messages = [...body.messages];
131
+ const content = [...messages[message].content];
132
+ const result = content[block];
133
+ content[block] = part === undefined
134
+ ? { ...result, content: text }
135
+ : { ...result, content: result.content.map((p, k) => (k === part ? { ...p, text } : p)) };
136
+ messages[message] = { ...messages[message], content };
137
+ return { ...body, messages };
138
+ }
139
+ export function firstRequestPrompt(body) {
140
+ const messages = Array.isArray(body?.messages) ? body.messages : [];
141
+ if (messages.some(message => message?.role === 'assistant'))
142
+ return null;
143
+ // Structured output (Claude Code's title side request) or a forced tool call cannot be a text reply.
144
+ if (body.output_config?.format || body.output_format)
145
+ return null;
146
+ if (body.tool_choice?.type === 'any' || body.tool_choice?.type === 'tool')
147
+ return null;
148
+ const lastUser = [...messages].reverse().find(message => message?.role === 'user');
149
+ const content = lastUser?.content;
150
+ const texts = typeof content === 'string'
151
+ ? [content]
152
+ : Array.isArray(content)
153
+ ? content.filter((block) => block?.type === 'text' && typeof block.text === 'string').map((block) => block.text)
154
+ : [];
155
+ const text = typedText(texts);
156
+ return text ? { text, toolsListed: Array.isArray(body.tools) && body.tools.length > 0 } : null;
157
+ }
158
+ export function buildLocalReply(params) {
159
+ const { text, stream, model, usage } = params;
160
+ const message = {
161
+ id: `msg_slmgate_${crypto.randomUUID().replace(/-/g, '')}`,
162
+ type: 'message',
163
+ role: 'assistant',
164
+ model,
165
+ content: [{ type: 'text', text }],
166
+ stop_reason: 'end_turn',
167
+ stop_sequence: null,
168
+ usage: { input_tokens: usage.inputTokens, output_tokens: usage.outputTokens },
169
+ };
170
+ if (!stream)
171
+ return { contentType: 'application/json', body: JSON.stringify(message) };
172
+ const event = (name, data) => `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
173
+ return {
174
+ contentType: 'text/event-stream; charset=utf-8',
175
+ body: event('message_start', {
176
+ type: 'message_start',
177
+ message: { ...message, content: [], stop_reason: null, usage: { input_tokens: usage.inputTokens, output_tokens: 1 } },
178
+ }) +
179
+ event('content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) +
180
+ event('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }) +
181
+ event('content_block_stop', { type: 'content_block_stop', index: 0 }) +
182
+ event('message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: usage.outputTokens } }) +
183
+ event('message_stop', { type: 'message_stop' }),
184
+ };
185
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @fileoverview OpenAI Chat Completions, for the model gate contract (formats/contract.ts).
3
+ *
4
+ * Tool results are `role: 'tool'` messages (legacy: `role: 'function'` with a `name`). Their `content`
5
+ * is a string or an array of parts whose `text` parts are the text. The call is the entry with the
6
+ * same id in a preceding assistant message's `tool_calls`; its `arguments` is a JSON string.
7
+ */
8
+ import crypto from 'node:crypto';
9
+ import { typedText } from './contract.js';
10
+ function parseArguments(raw) {
11
+ if (typeof raw !== 'string')
12
+ return raw;
13
+ try {
14
+ return JSON.parse(raw);
15
+ }
16
+ catch {
17
+ return raw;
18
+ }
19
+ }
20
+ export function listToolResults(body) {
21
+ const messages = Array.isArray(body?.messages) ? body.messages : [];
22
+ const calls = new Map();
23
+ let lastAssistant = -1;
24
+ messages.forEach((message, i) => {
25
+ if (message?.role !== 'assistant')
26
+ return;
27
+ lastAssistant = i;
28
+ for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) {
29
+ if (typeof call?.id === 'string')
30
+ calls.set(call.id, { name: String(call.function?.name ?? ''), args: parseArguments(call.function?.arguments) });
31
+ }
32
+ });
33
+ const results = [];
34
+ messages.forEach((message, i) => {
35
+ if (message?.role !== 'tool' && message?.role !== 'function')
36
+ return;
37
+ const call = calls.get(message.tool_call_id);
38
+ const found = { toolName: call?.name ?? String(message.name ?? ''), callArgs: call?.args, newTurn: i > lastAssistant };
39
+ if (typeof message.content === 'string') {
40
+ results.push({ ...found, location: { message: i }, text: message.content });
41
+ }
42
+ else if (Array.isArray(message.content)) {
43
+ message.content.forEach((part, k) => {
44
+ if (part?.type === 'text' && typeof part.text === 'string')
45
+ results.push({ ...found, location: { message: i, part: k }, text: part.text });
46
+ });
47
+ }
48
+ });
49
+ return results;
50
+ }
51
+ export function replaceToolResultText(params) {
52
+ const { body, location: { message, part }, text } = params;
53
+ const messages = [...body.messages];
54
+ const target = messages[message];
55
+ messages[message] = part === undefined
56
+ ? { ...target, content: text }
57
+ : { ...target, content: target.content.map((p, k) => (k === part ? { ...p, text } : p)) };
58
+ return { ...body, messages };
59
+ }
60
+ export function firstRequestPrompt(body) {
61
+ const messages = Array.isArray(body?.messages) ? body.messages : [];
62
+ if (messages.some(message => message?.role === 'assistant'))
63
+ return null;
64
+ if (['json_schema', 'json_object'].includes(body.response_format?.type))
65
+ return null;
66
+ if (body.tool_choice === 'required' || (body.tool_choice && typeof body.tool_choice === 'object'))
67
+ return null;
68
+ const lastUser = [...messages].reverse().find(message => message?.role === 'user');
69
+ const content = lastUser?.content;
70
+ const texts = typeof content === 'string'
71
+ ? [content]
72
+ : Array.isArray(content)
73
+ ? content.filter((part) => part?.type === 'text' && typeof part.text === 'string').map((part) => part.text)
74
+ : [];
75
+ const text = typedText(texts);
76
+ const toolsListed = (Array.isArray(body.tools) && body.tools.length > 0) || (Array.isArray(body.functions) && body.functions.length > 0);
77
+ return text ? { text, toolsListed } : null;
78
+ }
79
+ export function buildLocalReply(params) {
80
+ const { text, stream, model, usage } = params;
81
+ const id = `chatcmpl-slmgate-${crypto.randomUUID().replace(/-/g, '')}`;
82
+ const created = Math.floor(Date.now() / 1000);
83
+ const tokens = { prompt_tokens: usage.inputTokens, completion_tokens: usage.outputTokens, total_tokens: usage.inputTokens + usage.outputTokens };
84
+ if (!stream) {
85
+ return {
86
+ contentType: 'application/json',
87
+ body: JSON.stringify({
88
+ id,
89
+ object: 'chat.completion',
90
+ created,
91
+ model,
92
+ choices: [{ index: 0, message: { role: 'assistant', content: text }, logprobs: null, finish_reason: 'stop' }],
93
+ usage: tokens,
94
+ }),
95
+ };
96
+ }
97
+ const chunk = (delta, finishReason, extra = {}) => `data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created, model, choices: [{ index: 0, delta, logprobs: null, finish_reason: finishReason }], ...extra })}\n\n`;
98
+ return {
99
+ contentType: 'text/event-stream; charset=utf-8',
100
+ // Usage rides on the final chunk, so clients that asked for it get it and the rest ignore it.
101
+ body: chunk({ role: 'assistant', content: '' }, null) + chunk({ content: text }, null) + chunk({}, 'stop', { usage: tokens }) + 'data: [DONE]\n\n',
102
+ };
103
+ }