@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.
- package/.env.example +669 -0
- package/LICENSE +21 -0
- package/README.md +317 -0
- package/configs/antigravity/.env.16gb.example +674 -0
- package/configs/antigravity/.env.24gb.example +674 -0
- package/configs/antigravity/.env.32gb.example +674 -0
- package/configs/antigravity/README.md +109 -0
- package/configs/claude-code/.env.16gb.example +674 -0
- package/configs/claude-code/.env.24gb.example +674 -0
- package/configs/claude-code/.env.32gb.example +674 -0
- package/configs/claude-code/README.md +52 -0
- package/configs/claude-desktop/.env.16gb.example +674 -0
- package/configs/claude-desktop/.env.24gb.example +674 -0
- package/configs/claude-desktop/.env.32gb.example +674 -0
- package/configs/claude-desktop/README.md +37 -0
- package/configs/cline-continue-opencode/.env.16gb.example +674 -0
- package/configs/cline-continue-opencode/.env.24gb.example +674 -0
- package/configs/cline-continue-opencode/.env.32gb.example +674 -0
- package/configs/cline-continue-opencode/README.md +34 -0
- package/configs/cursor/.env.16gb.example +674 -0
- package/configs/cursor/.env.24gb.example +674 -0
- package/configs/cursor/.env.32gb.example +674 -0
- package/configs/cursor/README.md +26 -0
- package/configs/generic-http/.env.16gb.example +674 -0
- package/configs/generic-http/.env.24gb.example +674 -0
- package/configs/generic-http/.env.32gb.example +674 -0
- package/configs/generic-http/README.md +20 -0
- package/configs/generic-stdio/.env.16gb.example +674 -0
- package/configs/generic-stdio/.env.24gb.example +674 -0
- package/configs/generic-stdio/.env.32gb.example +674 -0
- package/configs/generic-stdio/README.md +24 -0
- package/configs/preserve/README.md +26 -0
- package/configs/preserve/tls.json +61 -0
- package/dist/adapters/tech-lead-stack.js +38 -0
- package/dist/cache/index.js +173 -0
- package/dist/cli.js +256 -0
- package/dist/config.js +255 -0
- package/dist/dashboard/data.js +149 -0
- package/dist/dashboard/export.js +42 -0
- package/dist/dashboard/serve.js +63 -0
- package/dist/doctor.js +338 -0
- package/dist/hardware.js +126 -0
- package/dist/home-dir.js +39 -0
- package/dist/ledger/flush-lifecycle.js +50 -0
- package/dist/ledger/index.js +946 -0
- package/dist/ledger/report.js +69 -0
- package/dist/ledger/setup-dashboard.js +456 -0
- package/dist/ledger/smoke.js +37 -0
- package/dist/ledger/sync-config.js +177 -0
- package/dist/ledger/sync.js +307 -0
- package/dist/ledger/verify.js +185 -0
- package/dist/ledger/wipe-langfuse.js +130 -0
- package/dist/llm-gate/distill.js +239 -0
- package/dist/llm-gate/formats/anthropic.js +185 -0
- package/dist/llm-gate/formats/chat-completions.js +103 -0
- package/dist/llm-gate/formats/contract.js +29 -0
- package/dist/llm-gate/formats/gemini.js +84 -0
- package/dist/llm-gate/formats/internal.js +1 -0
- package/dist/llm-gate/formats/openai.js +77 -0
- package/dist/llm-gate/formats/responses.js +146 -0
- package/dist/llm-gate/forward.js +150 -0
- package/dist/llm-gate/index.js +40 -0
- package/dist/llm-gate/local-first.js +217 -0
- package/dist/llm-gate/pipeline.js +267 -0
- package/dist/llm-gate/server.js +289 -0
- package/dist/mcp-gate/ground.js +64 -0
- package/dist/mcp-gate/index.js +57 -0
- package/dist/mcp-gate/pipeline.js +252 -0
- package/dist/mcp-gate/server.js +302 -0
- package/dist/mcp-gate/tool-names.js +57 -0
- package/dist/models/check.js +26 -0
- package/dist/models/footprint.js +137 -0
- package/dist/models/helpers.js +91 -0
- package/dist/models/index.js +5 -0
- package/dist/models/reasoning.js +91 -0
- package/dist/models/roles.js +9 -0
- package/dist/models/slm.js +243 -0
- package/dist/models/types.js +1 -0
- package/dist/pricing/index.js +115 -0
- package/dist/pricing/plans.js +54 -0
- package/dist/pricing/providers.js +172 -0
- package/dist/resolver/index.js +277 -0
- package/dist/resolver/types.js +1 -0
- package/dist/setup/claim.js +41 -0
- package/dist/setup/gate-command.js +41 -0
- package/dist/setup/init.js +92 -0
- package/dist/setup/local-models.js +123 -0
- package/dist/setup/model-gate.js +220 -0
- package/dist/setup/notify.js +45 -0
- package/dist/setup/ollama-install.js +53 -0
- package/dist/setup/parent-watch.js +84 -0
- package/dist/setup/required-models.js +20 -0
- package/dist/setup/startup.js +132 -0
- package/dist/setup/tool-settings.js +101 -0
- package/dist/utils/backoff.js +47 -0
- package/dist/utils/compression.js +145 -0
- package/dist/utils/constants.js +22 -0
- package/dist/utils/duration.js +43 -0
- package/dist/utils/elision.js +556 -0
- package/dist/utils/embedding.js +32 -0
- package/dist/utils/entry-point.js +23 -0
- package/dist/utils/local-only.js +82 -0
- package/dist/utils/preserve-patterns.js +115 -0
- package/dist/utils/safety.js +30 -0
- package/dist/verifier/index.js +67 -0
- package/package.json +121 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Step A: the local model answers the first request of a conversation when that is safe and
|
|
3
|
+
* it can. `attemptLocalAnswer` is the one implementation: the gate calls it through
|
|
4
|
+
* `answerFirstRequestLocally` (budget, cancellation, one attempt at a time) and the benchmark harness calls
|
|
5
|
+
* it through `processPipeline`, so the bench measures what ships.
|
|
6
|
+
*
|
|
7
|
+
* A wrong local answer is worse than a cloud call — the coding tool trusts the reply — so eligibility is
|
|
8
|
+
* narrow when the request lists tools (it nearly always does): only general questions and formatting, and
|
|
9
|
+
* never when the typed text mentions the workspace or asks about the assistant itself. The local model sees
|
|
10
|
+
* only the typed text, not the tool's system prompt or injected instructions.
|
|
11
|
+
*/
|
|
12
|
+
import { Ollama } from 'ollama';
|
|
13
|
+
import { checkSemanticCache, setSemanticCache } from '../cache/index.js';
|
|
14
|
+
import { CONFIG } from '../config.js';
|
|
15
|
+
import { getDb } from '../ledger/index.js';
|
|
16
|
+
import { handleSlmError } from '../models/helpers.js';
|
|
17
|
+
import { classify } from '../models/reasoning.js';
|
|
18
|
+
import { SLM } from '../models/slm.js';
|
|
19
|
+
import { verify } from '../verifier/index.js';
|
|
20
|
+
// The classifier's categories the local model may attempt; mirrors the schema in models/reasoning.ts
|
|
21
|
+
// (minus 'other'). Extending it means extending the classify() prompt too.
|
|
22
|
+
const ALLOWED = ['classify', 'extract', 'format', 'boolean', 'short_factual', 'trivial_edit'];
|
|
23
|
+
// With tools listed, only answers that cannot depend on the workspace or need a tool.
|
|
24
|
+
const ALLOWED_WITH_TOOLS = ['short_factual', 'format'];
|
|
25
|
+
const WORKSPACE_WORDS = /\b(?:repos?|repository|project|codebase|code|files?|folders?|director(?:y|ies)|dir|functions?|methods?|class(?:es)?|modules?|packages?|tests?|build|branch(?:es)?|commits?|diff|errors?|bugs?|config|scripts?|dependenc(?:y|ies)|workspace|components?|variables?)\b/i;
|
|
26
|
+
const FILE_NAME = /\b[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|py|go|rs|java|kt|rb|php|cs|cpp|c|h|swift|ya?ml|toml|lock|sh|css|scss|html|sql|env|txt|xml|gradle|ini|cfg)\b/i;
|
|
27
|
+
const PATH = /(?:^|\s)[.~]?\/\S|\b[\w-]+\/[\w./-]+/;
|
|
28
|
+
const IDENTIFIER = /\b[a-z]+[A-Z]\w*\b|\b[A-Za-z]+_\w+\b|\b\w+\(\)/;
|
|
29
|
+
const ASKS_ABOUT_ASSISTANT = /\b(?:who|what)\s+(?:are|r)\s+(?:you|u)\b|\byour\s+(?:name|model|version|capabilit\w*|purpose|creator)\b|\bwhat\s+can\s+you\s+do\b|\b(?:what|which)\s+model\b|\byourself\b|\bare\s+you\s+(?:an?\s+)?(?:ai|bot|claude|gpt|gemini|human)\b|^\s*\/?help\b/i;
|
|
30
|
+
/** True when the text refers to files, code or the project, which only the tool's own model can look at. */
|
|
31
|
+
export function mentionsWorkspace(text) {
|
|
32
|
+
return text.includes('`') || WORKSPACE_WORDS.test(text) || FILE_NAME.test(text) || PATH.test(text) || IDENTIFIER.test(text);
|
|
33
|
+
}
|
|
34
|
+
/** True for identity and capability questions, which only the tool's own model can answer as itself. */
|
|
35
|
+
export function asksAboutAssistant(text) {
|
|
36
|
+
return ASKS_ABOUT_ASSISTANT.test(text);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Share of recent local attempts in a category that the verifier accepted, counting only rows of the same
|
|
40
|
+
* environment so the benchmark and real traffic never train each other (no environment known: all rows).
|
|
41
|
+
* Null below `minSamples`.
|
|
42
|
+
*/
|
|
43
|
+
export function getCategorySuccessRate(params) {
|
|
44
|
+
try {
|
|
45
|
+
const rows = getDb().prepare(`
|
|
46
|
+
SELECT json_extract(meta, '$.local_accepted') as accepted
|
|
47
|
+
FROM events
|
|
48
|
+
WHERE layer = 'llm'
|
|
49
|
+
AND json_extract(meta, '$.category') = ?
|
|
50
|
+
AND json_extract(meta, '$.local_attempted') = 1
|
|
51
|
+
AND (? IS NULL OR environment = ?)
|
|
52
|
+
ORDER BY ts DESC
|
|
53
|
+
LIMIT ?
|
|
54
|
+
`).all(params.category, params.environment, params.environment, params.window);
|
|
55
|
+
if (rows.length < params.minSamples)
|
|
56
|
+
return null;
|
|
57
|
+
return rows.filter(row => row.accepted === 1 || row.accepted === true).length / rows.length;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null; // fail open, as before
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A cache hit is served only when the answer would be eligible now. Entries written here carry their
|
|
65
|
+
* category; plain strings (older entries, and the bench's cached cloud answers) have none, so they are
|
|
66
|
+
* served only when no tools are listed, as before.
|
|
67
|
+
*/
|
|
68
|
+
export function servableCacheHit(hit, toolsListed) {
|
|
69
|
+
if (typeof hit === 'string')
|
|
70
|
+
return toolsListed ? null : hit;
|
|
71
|
+
const entry = hit;
|
|
72
|
+
if (typeof entry?.answer !== 'string')
|
|
73
|
+
return null;
|
|
74
|
+
return !toolsListed || ALLOWED_WITH_TOOLS.includes(String(entry.category)) ? entry.answer : null;
|
|
75
|
+
}
|
|
76
|
+
/** A local-model client whose every request is cancelled when `signal` aborts (ollama-js takes a custom fetch). */
|
|
77
|
+
function localModelClient(signal) {
|
|
78
|
+
if (!signal)
|
|
79
|
+
return new SLM();
|
|
80
|
+
const abortableFetch = ((input, init) => fetch(input, { ...init, signal }));
|
|
81
|
+
return new SLM(new Ollama({ host: CONFIG.OLLAMA_HOST, fetch: abortableFetch }));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Asks the local model to answer, when the request is eligible, and checks the answer.
|
|
85
|
+
*
|
|
86
|
+
* @param params.task The typed text the answer is for
|
|
87
|
+
* @param params.messages What the model is given to answer
|
|
88
|
+
* @param params.toolsListed The request offers the model tools (narrows eligibility)
|
|
89
|
+
* @param params.routePolicy 'force-local' answers regardless of eligibility and verification (bench arms)
|
|
90
|
+
* @param params.localModel Answering model; defaults to SLM_BRAIN_MODEL
|
|
91
|
+
* @param params.environment Ledger environment ROUTING_TUNE learns from
|
|
92
|
+
* @param params.signal Aborting it cancels every model call; the attempt then returns no answer and stores nothing
|
|
93
|
+
* @returns What happened; never throws
|
|
94
|
+
*/
|
|
95
|
+
export async function attemptLocalAnswer(params) {
|
|
96
|
+
const { task, messages, toolsListed, routePolicy = 'auto', signal } = params;
|
|
97
|
+
const localModel = params.localModel ?? CONFIG.SLM_BRAIN_MODEL;
|
|
98
|
+
const result = { answer: null, attempted: false, accepted: false, verifierFlags: [], fromCache: false, model: localModel };
|
|
99
|
+
// Checked before any model call, so a workspace or identity question costs nothing.
|
|
100
|
+
if (routePolicy !== 'force-local' && toolsListed && (mentionsWorkspace(task) || asksAboutAssistant(task)))
|
|
101
|
+
return result;
|
|
102
|
+
if (CONFIG.SEMCACHE) {
|
|
103
|
+
let hit = null;
|
|
104
|
+
try {
|
|
105
|
+
// The cache's embedding call cannot take the attempt's signal, so stop waiting for it on abort;
|
|
106
|
+
// otherwise a slow embedding would hold the one-attempt-at-a-time slot past the budget.
|
|
107
|
+
hit = await unlessAborted(checkSemanticCache(task), signal);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
console.error(`[llm-gate] semantic cache lookup failed, treated as a miss: ${err instanceof Error ? err.message : String(err)}`);
|
|
111
|
+
}
|
|
112
|
+
if (signal?.aborted)
|
|
113
|
+
return result;
|
|
114
|
+
const cached = servableCacheHit(hit, toolsListed);
|
|
115
|
+
if (cached !== null)
|
|
116
|
+
return { ...result, answer: cached, fromCache: true, model: 'semcache', verifierFlags: ['cache_hit'] };
|
|
117
|
+
}
|
|
118
|
+
const slm = localModelClient(signal);
|
|
119
|
+
let category = 'other';
|
|
120
|
+
try {
|
|
121
|
+
if (task)
|
|
122
|
+
category = await classify(slm, task);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Classification failed: treated as 'other', as before.
|
|
126
|
+
}
|
|
127
|
+
if (signal?.aborted)
|
|
128
|
+
return result;
|
|
129
|
+
result.category = category;
|
|
130
|
+
let eligible = (toolsListed ? ALLOWED_WITH_TOOLS : ALLOWED).includes(category);
|
|
131
|
+
if (CONFIG.ROUTING_TUNE && eligible && routePolicy !== 'force-local' && Math.random() >= CONFIG.ROUTING_TUNE_EXPLORE_RATE) {
|
|
132
|
+
const rate = getCategorySuccessRate({
|
|
133
|
+
category,
|
|
134
|
+
window: CONFIG.ROUTING_TUNE_WINDOW,
|
|
135
|
+
minSamples: CONFIG.ROUTING_TUNE_MIN_SAMPLES,
|
|
136
|
+
environment: params.environment ?? CONFIG.LANGFUSE_ENVIRONMENT ?? null,
|
|
137
|
+
});
|
|
138
|
+
if (rate !== null && rate < CONFIG.ROUTING_TUNE_THRESHOLD)
|
|
139
|
+
eligible = false;
|
|
140
|
+
}
|
|
141
|
+
if (routePolicy !== 'force-local' && !eligible)
|
|
142
|
+
return result;
|
|
143
|
+
result.attempted = true;
|
|
144
|
+
try {
|
|
145
|
+
let samples = [];
|
|
146
|
+
let answer;
|
|
147
|
+
if (CONFIG.HEADLINE_STRICTNESS >= 4) {
|
|
148
|
+
// Self-consistency: several samples must agree (the verifier's level 4).
|
|
149
|
+
samples = await Promise.all(Array.from({ length: CONFIG.SELF_CONSISTENCY_K }, () => slm.generateText(localModel, messages, CONFIG.SELF_CONSISTENCY_TEMP)));
|
|
150
|
+
answer = samples[0];
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
answer = await slm.generateText(localModel, messages, CONFIG.TEMPERATURE);
|
|
154
|
+
}
|
|
155
|
+
if (signal?.aborted)
|
|
156
|
+
return result;
|
|
157
|
+
// localAccepted means the VERIFIER accepted the answer (non-empty, no hedging, samples agree) — a
|
|
158
|
+
// proxy for quality, not ground-truth correctness.
|
|
159
|
+
const verdict = verify(answer, samples, { nonEmpty: true }, CONFIG.HEADLINE_STRICTNESS);
|
|
160
|
+
result.verifierFlags = verdict.flags;
|
|
161
|
+
result.accepted = !verdict.escalate;
|
|
162
|
+
if (result.accepted || routePolicy === 'force-local')
|
|
163
|
+
result.answer = answer;
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
if (!signal?.aborted)
|
|
167
|
+
handleSlmError(err, 'llm-gate:generate', localModel);
|
|
168
|
+
}
|
|
169
|
+
if (result.accepted && result.answer !== null) {
|
|
170
|
+
// Remembering the answer is a bonus: a failure here must not cost the answer itself.
|
|
171
|
+
await setSemanticCache(task, { answer: result.answer, category }).catch(err => console.error(`[llm-gate] semantic cache write failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
/** Resolves like `work`, or with null as soon as `signal` aborts (the work itself keeps running). */
|
|
176
|
+
function unlessAborted(work, signal) {
|
|
177
|
+
if (!signal)
|
|
178
|
+
return work;
|
|
179
|
+
if (signal.aborted)
|
|
180
|
+
return Promise.resolve(null);
|
|
181
|
+
return Promise.race([work, new Promise(resolve => signal.addEventListener('abort', () => resolve(null), { once: true }))]);
|
|
182
|
+
}
|
|
183
|
+
let attemptRunning = false;
|
|
184
|
+
/**
|
|
185
|
+
* Step A for the gate: one attempt at a time, cancelled at LOCAL_ATTEMPT_BUDGET_MS. A first request that
|
|
186
|
+
* arrives while another attempt runs goes straight on (it never queues behind the local model).
|
|
187
|
+
*
|
|
188
|
+
* @returns The attempt, or null when it was skipped (busy) or ran out of time
|
|
189
|
+
*/
|
|
190
|
+
export async function answerFirstRequestLocally(params) {
|
|
191
|
+
if (attemptRunning)
|
|
192
|
+
return { attempt: null, outcome: 'busy' };
|
|
193
|
+
attemptRunning = true;
|
|
194
|
+
const controller = new AbortController();
|
|
195
|
+
const timer = setTimeout(() => controller.abort(), CONFIG.LOCAL_ATTEMPT_BUDGET_MS);
|
|
196
|
+
const work = attemptLocalAnswer({
|
|
197
|
+
task: params.task,
|
|
198
|
+
messages: [{ role: 'user', content: params.task }],
|
|
199
|
+
toolsListed: params.toolsListed,
|
|
200
|
+
environment: CONFIG.LANGFUSE_ENVIRONMENT,
|
|
201
|
+
signal: controller.signal,
|
|
202
|
+
}).finally(() => {
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
attemptRunning = false;
|
|
205
|
+
});
|
|
206
|
+
const expired = new Promise(resolve => controller.signal.addEventListener('abort', () => resolve(null), { once: true }));
|
|
207
|
+
const attempt = await Promise.race([work, expired]);
|
|
208
|
+
if (!attempt || controller.signal.aborted)
|
|
209
|
+
return { attempt: null, outcome: 'timeout' };
|
|
210
|
+
return { attempt, outcome: attempt.answer !== null ? 'answered' : 'declined' };
|
|
211
|
+
}
|
|
212
|
+
/** Loads the answering model in the background so the first eligible request does not pay the cold start. */
|
|
213
|
+
export function warmUpAnsweringModel() {
|
|
214
|
+
new SLM().generateText(CONFIG.SLM_BRAIN_MODEL, [{ role: 'user', content: 'ok' }], 0, 1).catch(() => {
|
|
215
|
+
// Ollama down or model missing: attempts then fall back to the normal path on their own.
|
|
216
|
+
});
|
|
217
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { setSemanticCache } from '../cache/index.js';
|
|
2
|
+
import { CONFIG } from '../config.js';
|
|
3
|
+
import { calculateCostUsd } from '../pricing/index.js';
|
|
4
|
+
import { waitWithBackoff as sharedWaitWithBackoff } from '../utils/backoff.js';
|
|
5
|
+
import { compressContext } from '../utils/compression.js';
|
|
6
|
+
import { isLatestInstructionFromTool } from '../utils/safety.js';
|
|
7
|
+
import { buildAnthropicRequest } from './formats/anthropic.js';
|
|
8
|
+
import { buildOpenAIRequest } from './formats/openai.js';
|
|
9
|
+
import { attemptLocalAnswer } from './local-first.js';
|
|
10
|
+
/**
|
|
11
|
+
* Determines whether an unknown thrown value is a transient, retryable network failure.
|
|
12
|
+
*
|
|
13
|
+
* @desc Inspects error name, system error code, and message signatures for network aborts/timeouts.
|
|
14
|
+
* @param err The caught unknown exception
|
|
15
|
+
* @returns True if the error is considered transient and safe to retry
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* if (isRetryableNetworkError(err) && attempt < MAX_RETRIES) { ... }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function isRetryableNetworkError(err) {
|
|
22
|
+
if (err instanceof Error) {
|
|
23
|
+
const code = err.code;
|
|
24
|
+
return err.name === 'TimeoutError' || code === 'ECONNRESET' || err.message.includes('fetch failed');
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Calculates exponential backoff with jitter and awaits the delay period.
|
|
30
|
+
*
|
|
31
|
+
* @desc Computes 1500ms * 2^attempt + jitter, respecting an optional `Retry-After` header value in seconds.
|
|
32
|
+
* Logs a diagnostic warning to stderr before waiting.
|
|
33
|
+
* @param attempt Current zero-indexed retry attempt
|
|
34
|
+
* @param maxRetries Total allowed retry attempts
|
|
35
|
+
* @param reason Human-readable context for why the backoff is being executed
|
|
36
|
+
* @param retryAfter Optional `Retry-After` header string from HTTP response
|
|
37
|
+
* @returns Promise that resolves once the backoff delay has completed
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* await waitWithBackoff(attempt, MAX_RETRIES, 'Upstream HTTP 429', res.headers.get('retry-after'));
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export async function waitWithBackoff(attempt, maxRetries, reason, retryAfter) {
|
|
44
|
+
// Implementation lives in utils/backoff.ts so the ledger's Langfuse flush can share it
|
|
45
|
+
// without a circular import. This wrapper preserves the existing call signature.
|
|
46
|
+
return sharedWaitWithBackoff(attempt, maxRetries, reason, retryAfter, 'llm-gate');
|
|
47
|
+
}
|
|
48
|
+
// Very basic token estimator. A real implementation would use a proper tokenizer like tiktoken.
|
|
49
|
+
function estimateTokens(text) {
|
|
50
|
+
return Math.ceil(text.length / 4);
|
|
51
|
+
}
|
|
52
|
+
function countMessagesTokens(messages, system) {
|
|
53
|
+
let text = system || '';
|
|
54
|
+
for (const m of messages) {
|
|
55
|
+
text += '\n' + m.content;
|
|
56
|
+
}
|
|
57
|
+
return estimateTokens(text);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The core orchestration pipeline for a single LLM Gate request.
|
|
61
|
+
*
|
|
62
|
+
* Pipeline flow:
|
|
63
|
+
* 1. Derives the core task from the last user message.
|
|
64
|
+
* 2. If routePolicy allows local deferral and heuristics approve (no tool injection),
|
|
65
|
+
* classifies the task with the SLM.
|
|
66
|
+
* 3. If the task type is simple enough, generates an answer locally.
|
|
67
|
+
* 4. Verifies the local answer against rigorous constraints.
|
|
68
|
+
* 5. If verified and safe, returns the local answer (deferring the cloud call entirely).
|
|
69
|
+
* 6. If it escalates or is forced, compresses the context and calls the upstream Cloud API.
|
|
70
|
+
*
|
|
71
|
+
* @param reqId A unique request identifier
|
|
72
|
+
* @param internalReq The internal request representation
|
|
73
|
+
* @param options Routing configurations
|
|
74
|
+
* @returns A fully constructed response payload and detailed token/cost analytics
|
|
75
|
+
*/
|
|
76
|
+
export async function processPipeline(reqId, internalReq, options) {
|
|
77
|
+
console.info('LLM Gate Pipeline: Started');
|
|
78
|
+
const t0 = Date.now();
|
|
79
|
+
const messages = internalReq.messages;
|
|
80
|
+
const result = {
|
|
81
|
+
body: null,
|
|
82
|
+
route: 'forward_raw',
|
|
83
|
+
isLocal: false,
|
|
84
|
+
model: '',
|
|
85
|
+
inTok: countMessagesTokens(messages, internalReq.system),
|
|
86
|
+
outTok: 0,
|
|
87
|
+
apiInTok: 0,
|
|
88
|
+
apiOutTok: 0,
|
|
89
|
+
costUsd: 0,
|
|
90
|
+
slmLatency: 0,
|
|
91
|
+
apiLatency: 0,
|
|
92
|
+
verifierFlags: [],
|
|
93
|
+
category: undefined,
|
|
94
|
+
localAttempted: false,
|
|
95
|
+
localAccepted: false,
|
|
96
|
+
promptChars: 0,
|
|
97
|
+
promptTokEst: 0,
|
|
98
|
+
hasCodeFence: false
|
|
99
|
+
};
|
|
100
|
+
const isSafeForLocal = !isLatestInstructionFromTool(messages);
|
|
101
|
+
const routePolicy = options.routePolicy;
|
|
102
|
+
let localDeferred = false;
|
|
103
|
+
let localAnswer = '';
|
|
104
|
+
let localModel = options.localModel || CONFIG.SLM_BRAIN_MODEL;
|
|
105
|
+
// Derive task (last user message)
|
|
106
|
+
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
|
107
|
+
let taskText = lastUserMsg ? lastUserMsg.content : '';
|
|
108
|
+
// Normalize by stripping simple timestamps/ids from text if needed, but for now we'll just use taskText
|
|
109
|
+
const normalizedText = taskText;
|
|
110
|
+
result.promptChars = taskText.length;
|
|
111
|
+
result.promptTokEst = estimateTokens(taskText);
|
|
112
|
+
result.hasCodeFence = /```/.test(taskText);
|
|
113
|
+
// Local answer: the same implementation the model gate uses (local-first.ts), so the bench measures
|
|
114
|
+
// what ships. It includes the semantic cache, which is therefore only consulted on this path now.
|
|
115
|
+
let fromCache = false;
|
|
116
|
+
if (routePolicy === 'force-local' || (routePolicy === 'auto' && isSafeForLocal)) {
|
|
117
|
+
const attempt = await attemptLocalAnswer({
|
|
118
|
+
task: taskText,
|
|
119
|
+
messages,
|
|
120
|
+
toolsListed: (internalReq.tools?.length ?? 0) > 0,
|
|
121
|
+
routePolicy,
|
|
122
|
+
localModel,
|
|
123
|
+
environment: options.environment,
|
|
124
|
+
});
|
|
125
|
+
result.category = attempt.category;
|
|
126
|
+
result.localAttempted = attempt.attempted;
|
|
127
|
+
result.localAccepted = attempt.accepted;
|
|
128
|
+
result.verifierFlags = attempt.verifierFlags;
|
|
129
|
+
if (attempt.answer !== null) {
|
|
130
|
+
localDeferred = true;
|
|
131
|
+
localAnswer = attempt.answer;
|
|
132
|
+
localModel = attempt.model;
|
|
133
|
+
fromCache = attempt.fromCache;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
result.slmLatency = (Date.now() - t0) / 1000;
|
|
137
|
+
if (localDeferred) {
|
|
138
|
+
result.route = 'defer_local';
|
|
139
|
+
result.isLocal = true;
|
|
140
|
+
result.model = localModel;
|
|
141
|
+
result.outTok = estimateTokens(localAnswer);
|
|
142
|
+
result.costUsd = fromCache ? 0 : calculateCostUsd(localModel, result.inTok, result.outTok);
|
|
143
|
+
// Format local answer as a standard completion in the internal format
|
|
144
|
+
// Since we stream in the server based on the return format, here we just return the full response.
|
|
145
|
+
// Streaming wrapper is handled outside if internalReq.stream is true.
|
|
146
|
+
result.body = {
|
|
147
|
+
choices: [
|
|
148
|
+
{
|
|
149
|
+
message: {
|
|
150
|
+
role: 'assistant',
|
|
151
|
+
content: localAnswer
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
]
|
|
155
|
+
};
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
// Fallthrough: Escalate to Cloud
|
|
159
|
+
const t1 = Date.now();
|
|
160
|
+
let compressedReq = { ...internalReq };
|
|
161
|
+
if (routePolicy !== 'raw') {
|
|
162
|
+
compressedReq.messages = await compressContext(messages);
|
|
163
|
+
result.route = 'forward_compressed';
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
result.route = 'forward_raw';
|
|
167
|
+
}
|
|
168
|
+
// The system prompt goes out as the client sent it: the gate adds nothing to the model's instructions
|
|
169
|
+
// and names no toolbox (toolboxes are plug-and-play behind the MCP layer).
|
|
170
|
+
// Format the request for the cloud provider
|
|
171
|
+
let fetchUrl = CONFIG.CLOUD_BASE_URL;
|
|
172
|
+
let fetchHeaders = {
|
|
173
|
+
'Content-Type': 'application/json'
|
|
174
|
+
};
|
|
175
|
+
let fetchBody;
|
|
176
|
+
if (CONFIG.CLOUD_API_STYLE === 'anthropic') {
|
|
177
|
+
if (fetchUrl && !fetchUrl.endsWith('/messages')) {
|
|
178
|
+
fetchUrl = fetchUrl.replace(/\/+$/, '') + '/messages';
|
|
179
|
+
}
|
|
180
|
+
else if (!fetchUrl) {
|
|
181
|
+
fetchUrl = 'https://api.anthropic.com/v1/messages';
|
|
182
|
+
}
|
|
183
|
+
fetchHeaders['x-api-key'] = CONFIG.CLOUD_API_KEY;
|
|
184
|
+
fetchHeaders['anthropic-version'] = '2023-06-01';
|
|
185
|
+
fetchBody = buildAnthropicRequest(compressedReq);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// openai style
|
|
189
|
+
if (fetchUrl && !fetchUrl.endsWith('/chat/completions')) {
|
|
190
|
+
fetchUrl = fetchUrl.replace(/\/+$/, '') + '/chat/completions';
|
|
191
|
+
}
|
|
192
|
+
else if (!fetchUrl) {
|
|
193
|
+
fetchUrl = 'https://api.openai.com/v1/chat/completions';
|
|
194
|
+
}
|
|
195
|
+
fetchHeaders['Authorization'] = `Bearer ${CONFIG.CLOUD_API_KEY}`;
|
|
196
|
+
fetchBody = buildOpenAIRequest(compressedReq);
|
|
197
|
+
}
|
|
198
|
+
result.apiInTok = countMessagesTokens(compressedReq.messages, compressedReq.system);
|
|
199
|
+
// Perform API request with exponential backoff for 429 / 5xx and transient network issues
|
|
200
|
+
const MAX_RETRIES = 3;
|
|
201
|
+
let lastErr = null;
|
|
202
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
203
|
+
try {
|
|
204
|
+
const apiRes = await fetch(fetchUrl, {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: fetchHeaders,
|
|
207
|
+
body: JSON.stringify(fetchBody),
|
|
208
|
+
signal: AbortSignal.timeout(30000)
|
|
209
|
+
});
|
|
210
|
+
if (!apiRes.ok) {
|
|
211
|
+
const isRateLimit = apiRes.status === 429;
|
|
212
|
+
const isServerErr = apiRes.status >= 500 && apiRes.status < 600;
|
|
213
|
+
if ((isRateLimit || isServerErr) && attempt < MAX_RETRIES) {
|
|
214
|
+
await waitWithBackoff(attempt, MAX_RETRIES, `Upstream HTTP ${apiRes.status} (${apiRes.statusText})`, apiRes.headers.get('retry-after'));
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
throw new Error(`Cloud API Error: ${apiRes.statusText || apiRes.status}`);
|
|
218
|
+
}
|
|
219
|
+
if (internalReq.stream) {
|
|
220
|
+
// In stream mode, we return the stream to the caller
|
|
221
|
+
result.body = apiRes.body; // Pass the readable stream
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
const data = await apiRes.json();
|
|
225
|
+
result.body = data;
|
|
226
|
+
// Token counting
|
|
227
|
+
const usage = data?.usage;
|
|
228
|
+
if (CONFIG.CLOUD_API_STYLE === 'anthropic') {
|
|
229
|
+
result.apiOutTok = usage?.output_tokens || 0;
|
|
230
|
+
result.apiInTok = usage?.input_tokens || result.apiInTok;
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
result.apiOutTok = usage?.completion_tokens || 0;
|
|
234
|
+
result.apiInTok = usage?.prompt_tokens || result.apiInTok;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
lastErr = null;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
const errorObj = err instanceof Error ? err : new Error(String(err));
|
|
242
|
+
lastErr = errorObj;
|
|
243
|
+
if (isRetryableNetworkError(err) && attempt < MAX_RETRIES) {
|
|
244
|
+
await waitWithBackoff(attempt, MAX_RETRIES, `Cloud network error (${errorObj.message})`);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (lastErr) {
|
|
251
|
+
throw new Error(`Cloud request failed: ${lastErr.message}`);
|
|
252
|
+
}
|
|
253
|
+
if (isSafeForLocal && CONFIG.SEMCACHE) {
|
|
254
|
+
let responseText = '';
|
|
255
|
+
if (result.body && result.body.choices && result.body.choices[0] && result.body.choices[0].message) {
|
|
256
|
+
responseText = result.body.choices[0].message.content;
|
|
257
|
+
if (responseText) {
|
|
258
|
+
await setSemanticCache(normalizedText, responseText);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
result.apiLatency = (Date.now() - t1) / 1000;
|
|
263
|
+
result.model = CONFIG.CLOUD_MODEL || 'unknown';
|
|
264
|
+
result.outTok = result.apiOutTok;
|
|
265
|
+
result.costUsd = calculateCostUsd(result.model, result.apiInTok, result.apiOutTok);
|
|
266
|
+
return result;
|
|
267
|
+
}
|