@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,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
+ }