@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,29 @@
1
+ /**
2
+ * @fileoverview The one contract every wire-format module implements for the model gate.
3
+ *
4
+ * Four real formats (Anthropic Messages, Chat Completions, Responses, Gemini) justify this small shared
5
+ * shape; everything else stays local to each format module. Request bodies are plain parsed JSON.
6
+ */
7
+ // Coding tools inject context as blocks wrapped in one tag (<system-reminder>…</system-reminder>,
8
+ // <ide_opened_file>…</ide_opened_file>, <environment_context>…</environment_context>).
9
+ const INJECTED_BLOCK = /^\s*<([A-Za-z][\w-]*)[^>]*>[\s\S]*<\/\1>\s*$/;
10
+ // A slash command runs the tool's own workflow, which only the tool's model can do. Claude Code marks the
11
+ // expanded command with these tags; a client that sends the command literally starts with a bare token
12
+ // (`/review add x`, `/plugin:plan`, `/help`) — never a path (`/Users/me/a.ts`) or a number (`/2`).
13
+ const COMMAND_MARKER = /<command-(?:name|message)>/;
14
+ const LITERAL_COMMAND = /^\/[A-Za-z][\w-]*(?::[\w-]+)?(?:\s|$)/;
15
+ /**
16
+ * What the person typed, from the text parts of the latest user entry: the last part that is not one
17
+ * injected tag-wrapped block. Null for a slash command, which is never answered locally. Shared by the
18
+ * format modules.
19
+ */
20
+ export function typedText(texts) {
21
+ if (texts.some(text => COMMAND_MARKER.test(text)))
22
+ return null;
23
+ for (let i = texts.length - 1; i >= 0; i--) {
24
+ const text = texts[i].trim();
25
+ if (text && !INJECTED_BLOCK.test(text))
26
+ return LITERAL_COMMAND.test(text) ? null : text;
27
+ }
28
+ return null;
29
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @fileoverview Gemini generateContent, for the model gate contract (formats/contract.ts).
3
+ *
4
+ * Tool results are `functionResponse` parts in user contents; Gemini CLI puts the text in
5
+ * `response.output` (other response shapes are left alone). The call is the `functionCall` part with
6
+ * the same id (or, without ids, the latest earlier call of that name). Thought signatures are sibling
7
+ * keys on model parts: parts are never merged, split, reordered or rebuilt, only the one string changes.
8
+ */
9
+ import crypto from 'node:crypto';
10
+ import { typedText } from './contract.js';
11
+ export function listToolResults(body) {
12
+ const contents = Array.isArray(body?.contents) ? body.contents : [];
13
+ const callsById = new Map();
14
+ const lastCallByName = new Map();
15
+ let lastModel = -1;
16
+ const results = [];
17
+ // One pass in order, so "latest earlier call of that name" means earlier than the response.
18
+ contents.forEach((content, i) => {
19
+ const parts = Array.isArray(content?.parts) ? content.parts : [];
20
+ if (content?.role === 'model') {
21
+ lastModel = i;
22
+ for (const part of parts) {
23
+ const call = part?.functionCall;
24
+ if (!call)
25
+ continue;
26
+ if (typeof call.id === 'string')
27
+ callsById.set(call.id, call.args);
28
+ if (typeof call.name === 'string')
29
+ lastCallByName.set(call.name, call.args);
30
+ }
31
+ return;
32
+ }
33
+ parts.forEach((part, j) => {
34
+ const response = part?.functionResponse;
35
+ if (typeof response?.response?.output !== 'string')
36
+ return;
37
+ const name = String(response.name ?? '');
38
+ const callArgs = typeof response.id === 'string' && callsById.has(response.id) ? callsById.get(response.id) : lastCallByName.get(name);
39
+ results.push({ location: { content: i, part: j }, text: response.response.output, toolName: name, callArgs, newTurn: false });
40
+ });
41
+ });
42
+ // Only now is the last model turn known.
43
+ return results.map(result => ({ ...result, newTurn: result.location.content > lastModel }));
44
+ }
45
+ export function replaceToolResultText(params) {
46
+ const { body, location: { content, part }, text } = params;
47
+ const contents = [...body.contents];
48
+ const parts = [...contents[content].parts];
49
+ const target = parts[part];
50
+ parts[part] = {
51
+ ...target,
52
+ functionResponse: { ...target.functionResponse, response: { ...target.functionResponse.response, output: text } },
53
+ };
54
+ contents[content] = { ...contents[content], parts };
55
+ return { ...body, contents };
56
+ }
57
+ export function firstRequestPrompt(body) {
58
+ const contents = Array.isArray(body?.contents) ? body.contents : [];
59
+ if (contents.some(content => content?.role === 'model'))
60
+ return null;
61
+ const config = body.generationConfig ?? {};
62
+ if (config.responseSchema || config.responseJsonSchema || config.responseMimeType === 'application/json')
63
+ return null;
64
+ if (body.toolConfig?.functionCallingConfig?.mode === 'ANY')
65
+ return null;
66
+ const lastUser = [...contents].reverse().find(content => content?.role === 'user' || content?.role === undefined);
67
+ const parts = Array.isArray(lastUser?.parts) ? lastUser.parts : [];
68
+ const text = typedText(parts.filter(part => typeof part?.text === 'string' && !part.thought).map(part => part.text));
69
+ const toolsListed = Array.isArray(body.tools) && body.tools.some((tool) => !Array.isArray(tool?.functionDeclarations) || tool.functionDeclarations.length > 0);
70
+ return text ? { text, toolsListed } : null;
71
+ }
72
+ export function buildLocalReply(params) {
73
+ const { text, stream, model, usage } = params;
74
+ const reply = {
75
+ candidates: [{ content: { role: 'model', parts: [{ text }] }, finishReason: 'STOP', index: 0 }],
76
+ usageMetadata: { promptTokenCount: usage.inputTokens, candidatesTokenCount: usage.outputTokens, totalTokenCount: usage.inputTokens + usage.outputTokens },
77
+ modelVersion: model,
78
+ responseId: `slmgate-${crypto.randomUUID()}`,
79
+ };
80
+ // Streamed replies are server-sent events (`?alt=sse`), which is what Gemini CLI asks for.
81
+ return stream
82
+ ? { contentType: 'text/event-stream', body: `data: ${JSON.stringify(reply)}\r\n\r\n` }
83
+ : { contentType: 'application/json; charset=UTF-8', body: JSON.stringify(reply) };
84
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Parses an incoming OpenAI-formatted chat completion request and translates it
3
+ * into the agnostic `InternalRequest` structure.
4
+ * It also hoists the first "system" message out of the messages array for alignment.
5
+ *
6
+ * @param body The raw JSON body of an OpenAI API request
7
+ * @param modelFallback A default model to use if the request omits it
8
+ */
9
+ export function parseOpenAIRequest(body, modelFallback) {
10
+ const messages = [];
11
+ let system;
12
+ for (const m of (body.messages || [])) {
13
+ if (m.role === 'system') {
14
+ system = m.content; // Grab the first system message
15
+ // We still include it in messages so that when we serialize to OpenAI it remains
16
+ messages.push({ role: 'system', content: m.content });
17
+ }
18
+ else if (m.role === 'tool' || m.role === 'function') {
19
+ messages.push({ role: 'tool', content: m.content });
20
+ }
21
+ else if (m.role === 'user' || m.role === 'assistant') {
22
+ messages.push({ role: m.role, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) });
23
+ }
24
+ }
25
+ return {
26
+ system,
27
+ messages,
28
+ maxTokens: body.max_tokens,
29
+ stream: !!body.stream,
30
+ tools: body.tools,
31
+ model: body.model || modelFallback
32
+ };
33
+ }
34
+ export function buildOpenAIRequest(internal) {
35
+ // Strip any existing system messages to avoid duplication
36
+ const cleanMessages = internal.messages.filter(m => m.role !== 'system');
37
+ const finalMessages = cleanMessages.map(m => ({ role: m.role, content: m.content }));
38
+ if (internal.system) {
39
+ finalMessages.unshift({ role: 'system', content: internal.system });
40
+ }
41
+ const req = {
42
+ model: internal.model,
43
+ messages: finalMessages,
44
+ };
45
+ if (internal.maxTokens !== undefined) {
46
+ req.max_tokens = internal.maxTokens;
47
+ }
48
+ if (internal.tools && internal.tools.length > 0) {
49
+ req.tools = internal.tools;
50
+ }
51
+ if (internal.stream) {
52
+ req.stream = true;
53
+ req.stream_options = { include_usage: true };
54
+ }
55
+ return req;
56
+ }
57
+ export function formatOpenAIStreamChunk(id, model, content, finishReason = null, usage = null) {
58
+ const chunk = {
59
+ id,
60
+ object: 'chat.completion.chunk',
61
+ created: Math.floor(Date.now() / 1000),
62
+ model,
63
+ choices: []
64
+ };
65
+ if (content !== '' || finishReason !== null) {
66
+ chunk.choices.push({
67
+ index: 0,
68
+ delta: content ? { content } : {},
69
+ finish_reason: finishReason
70
+ });
71
+ }
72
+ if (usage) {
73
+ chunk.usage = usage;
74
+ }
75
+ return `data: ${JSON.stringify(chunk)}\n\n`;
76
+ }
77
+ export const OPENAI_STREAM_DONE = 'data: [DONE]\n\n';
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @fileoverview OpenAI Responses (Codex), for the model gate contract (formats/contract.ts).
3
+ *
4
+ * Tool results are `function_call_output`, `custom_tool_call_output` or `local_shell_call_output` items
5
+ * in `input`; their `output` is a string or an array whose `input_text` parts are the text (images are
6
+ * left alone). The call is the `function_call` / `custom_tool_call` / `local_shell_call` item with the
7
+ * same `call_id`. Reasoning items (including encrypted reasoning) are never touched.
8
+ */
9
+ import crypto from 'node:crypto';
10
+ import { typedText } from './contract.js';
11
+ const OUTPUT_TYPES = new Set(['function_call_output', 'custom_tool_call_output', 'local_shell_call_output']);
12
+ function parseArguments(raw) {
13
+ if (typeof raw !== 'string')
14
+ return raw;
15
+ try {
16
+ return JSON.parse(raw);
17
+ }
18
+ catch {
19
+ return raw;
20
+ }
21
+ }
22
+ /** An item the model produced: an assistant message, a reasoning item, or any call it made. */
23
+ function isModelItem(item) {
24
+ if (item?.role === 'assistant')
25
+ return true;
26
+ const type = item?.type;
27
+ return typeof type === 'string' && type !== 'message' && type !== 'item_reference' && !type.endsWith('_output');
28
+ }
29
+ function callOf(item) {
30
+ switch (item?.type) {
31
+ case 'function_call':
32
+ return { name: String(item.name ?? ''), args: parseArguments(item.arguments) };
33
+ case 'custom_tool_call':
34
+ return { name: String(item.name ?? ''), args: parseArguments(item.input) };
35
+ case 'local_shell_call':
36
+ return { name: 'local_shell', args: { command: item.action?.command } };
37
+ default:
38
+ return null;
39
+ }
40
+ }
41
+ export function listToolResults(body) {
42
+ const items = Array.isArray(body?.input) ? body.input : [];
43
+ const calls = new Map();
44
+ let lastModelItem = -1;
45
+ items.forEach((item, i) => {
46
+ if (isModelItem(item))
47
+ lastModelItem = i;
48
+ const call = callOf(item);
49
+ if (call && typeof item.call_id === 'string')
50
+ calls.set(item.call_id, call);
51
+ });
52
+ const results = [];
53
+ items.forEach((item, i) => {
54
+ if (!OUTPUT_TYPES.has(item?.type))
55
+ return;
56
+ const call = calls.get(item.call_id);
57
+ const found = { toolName: call?.name ?? '', callArgs: call?.args, newTurn: i > lastModelItem };
58
+ if (typeof item.output === 'string') {
59
+ results.push({ ...found, location: { item: i }, text: item.output });
60
+ }
61
+ else if (Array.isArray(item.output)) {
62
+ item.output.forEach((part, k) => {
63
+ if (part?.type === 'input_text' && typeof part.text === 'string')
64
+ results.push({ ...found, location: { item: i, part: k }, text: part.text });
65
+ });
66
+ }
67
+ });
68
+ return results;
69
+ }
70
+ export function replaceToolResultText(params) {
71
+ const { body, location: { item, part }, text } = params;
72
+ const input = [...body.input];
73
+ const target = input[item];
74
+ input[item] = part === undefined
75
+ ? { ...target, output: text }
76
+ : { ...target, output: target.output.map((p, k) => (k === part ? { ...p, text } : p)) };
77
+ return { ...body, input };
78
+ }
79
+ export function firstRequestPrompt(body) {
80
+ if (body.previous_response_id)
81
+ return null;
82
+ if (['json_schema', 'json_object'].includes(body.text?.format?.type))
83
+ return null;
84
+ if (body.tool_choice === 'required' || (body.tool_choice && typeof body.tool_choice === 'object'))
85
+ return null;
86
+ const toolsListed = Array.isArray(body.tools) && body.tools.length > 0;
87
+ if (typeof body.input === 'string') {
88
+ const text = typedText([body.input]);
89
+ return text ? { text, toolsListed } : null;
90
+ }
91
+ const items = Array.isArray(body.input) ? body.input : [];
92
+ if (items.some(isModelItem))
93
+ return null;
94
+ const lastUser = [...items].reverse().find(item => item?.role === 'user');
95
+ const content = lastUser?.content;
96
+ const texts = typeof content === 'string'
97
+ ? [content]
98
+ : Array.isArray(content)
99
+ ? content.filter((part) => part?.type === 'input_text' && typeof part.text === 'string').map((part) => part.text)
100
+ : [];
101
+ const text = typedText(texts);
102
+ return text ? { text, toolsListed } : null;
103
+ }
104
+ export function buildLocalReply(params) {
105
+ const { text, stream, model, usage } = params;
106
+ const suffix = crypto.randomUUID().replace(/-/g, '');
107
+ const itemId = `msg_slmgate_${suffix}`;
108
+ const part = { type: 'output_text', text, annotations: [] };
109
+ const item = { type: 'message', id: itemId, status: 'completed', role: 'assistant', content: [part] };
110
+ const response = {
111
+ id: `resp_slmgate_${suffix}`,
112
+ object: 'response',
113
+ created_at: Math.floor(Date.now() / 1000),
114
+ status: 'completed',
115
+ model,
116
+ output: [item],
117
+ error: null,
118
+ incomplete_details: null,
119
+ usage: {
120
+ input_tokens: usage.inputTokens,
121
+ input_tokens_details: { cached_tokens: 0 },
122
+ output_tokens: usage.outputTokens,
123
+ output_tokens_details: { reasoning_tokens: 0 },
124
+ total_tokens: usage.inputTokens + usage.outputTokens,
125
+ },
126
+ };
127
+ if (!stream)
128
+ return { contentType: 'application/json', body: JSON.stringify(response) };
129
+ const inProgress = { ...response, status: 'in_progress', output: [], usage: null };
130
+ const at = { item_id: itemId, output_index: 0, content_index: 0 };
131
+ const events = [
132
+ ['response.created', { response: inProgress }],
133
+ ['response.in_progress', { response: inProgress }],
134
+ ['response.output_item.added', { output_index: 0, item: { ...item, status: 'in_progress', content: [] } }],
135
+ ['response.content_part.added', { ...at, part: { ...part, text: '' } }],
136
+ ['response.output_text.delta', { ...at, delta: text }],
137
+ ['response.output_text.done', { ...at, text }],
138
+ ['response.content_part.done', { ...at, part }],
139
+ ['response.output_item.done', { output_index: 0, item }],
140
+ ['response.completed', { response }],
141
+ ];
142
+ return {
143
+ contentType: 'text/event-stream; charset=utf-8',
144
+ body: events.map(([type, data], sequence) => `event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: sequence, ...data })}\n\n`).join(''),
145
+ };
146
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @fileoverview Forwards a model request, unchanged, to the provider its wire format belongs to.
3
+ *
4
+ * The gate never translates between formats and never swaps credentials: every client header except
5
+ * hop-by-hop ones goes upstream, so the tool keeps its own login (API key, claude.ai login, ChatGPT
6
+ * login). Bytes pass through raw in both directions. That rules out `fetch`, which decompresses a
7
+ * response but keeps its `content-encoding` header — Claude Code's streams arrive gzip-encoded.
8
+ * There is no timeout and no retry: streams can run for many minutes, and every tool retries itself.
9
+ */
10
+ import http from 'node:http';
11
+ import https from 'node:https';
12
+ import { CONFIG } from '../config.js';
13
+ /** Listed in the 404 a tool gets for any other path. */
14
+ export const SUPPORTED_PATHS = [
15
+ 'POST /v1/messages',
16
+ 'POST /v1/messages/count_tokens',
17
+ 'HEAD /api/hello',
18
+ 'GET /v1/models (with an anthropic-version header)',
19
+ 'POST /v1/chat/completions',
20
+ 'POST /v1/responses',
21
+ 'POST /v1beta/models/{model}:generateContent',
22
+ 'POST /v1beta/models/{model}:streamGenerateContent',
23
+ 'POST /v1beta/models/{model}:countTokens',
24
+ ];
25
+ // Describe this hop, not the request, so they are never forwarded in either direction.
26
+ const HOP_BY_HOP = new Set(['host', 'connection', 'content-length', 'transfer-encoding', 'keep-alive', 'proxy-connection', 'upgrade', 'te', 'trailer']);
27
+ // countTokens is included because Gemini CLI sends it for prompts that carry media.
28
+ const GEMINI_PATH = /^\/v1beta\/models\/([^/:]+):(generateContent|streamGenerateContent|countTokens)$/;
29
+ function upstreamUrl(params) {
30
+ return new URL(params.base.replace(/\/+$/, '') + params.path + params.search);
31
+ }
32
+ /**
33
+ * PROVISIONAL (Slice 0 finding F24, not yet confirmed against a real Codex ChatGPT login): a ChatGPT
34
+ * login sends a ChatGPT access token, which is a JWT, plus a `chatgpt-account-id` header; an API-key
35
+ * login sends an `sk-` key. Only the former may go to the ChatGPT backend.
36
+ */
37
+ function isChatgptLogin(headers) {
38
+ if (headers['chatgpt-account-id'] !== undefined)
39
+ return true;
40
+ const token = /^Bearer\s+(.+)$/i.exec(String(headers.authorization ?? ''))?.[1] ?? '';
41
+ return /^eyJ[\w-]*\.[\w-]+\.[\w-]*$/.test(token);
42
+ }
43
+ /**
44
+ * Picks the upstream for a request from its path (and, where two formats share a path, its headers).
45
+ *
46
+ * @param params.pathAndQuery The request target as received, e.g. `/v1/messages?beta=true`
47
+ * @param params.headers The client's request headers
48
+ * @returns The route, or null when the gate does not handle that path
49
+ */
50
+ export function resolveUpstream(params) {
51
+ const { pathAndQuery, headers } = params;
52
+ const { pathname, search } = new URL(pathAndQuery, 'http://gate.local');
53
+ const anthropic = (generation) => ({ format: 'anthropic', url: upstreamUrl({ base: CONFIG.UPSTREAM_ANTHROPIC_URL, path: pathname, search }), generation });
54
+ switch (pathname) {
55
+ case '/v1/messages':
56
+ return anthropic(true);
57
+ case '/v1/messages/count_tokens':
58
+ case '/api/hello':
59
+ return anthropic(false);
60
+ case '/v1/models':
61
+ // OpenAI-format tools list models on the same path; only Anthropic clients send this header.
62
+ return headers['anthropic-version'] === undefined ? null : anthropic(false);
63
+ case '/v1/chat/completions':
64
+ return { format: 'chat-completions', url: upstreamUrl({ base: CONFIG.UPSTREAM_OPENAI_URL, path: '/chat/completions', search }), generation: true };
65
+ case '/v1/responses': {
66
+ const base = isChatgptLogin(headers) ? CONFIG.UPSTREAM_CHATGPT_URL : CONFIG.UPSTREAM_OPENAI_URL;
67
+ return { format: 'responses', url: upstreamUrl({ base, path: '/responses', search }), generation: true };
68
+ }
69
+ }
70
+ const gemini = GEMINI_PATH.exec(pathname);
71
+ if (!gemini)
72
+ return null;
73
+ return {
74
+ format: 'gemini',
75
+ url: upstreamUrl({ base: CONFIG.UPSTREAM_GEMINI_URL, path: pathname, search }),
76
+ generation: gemini[2] !== 'countTokens',
77
+ pathModel: gemini[1],
78
+ ...(gemini[2] === 'streamGenerateContent'
79
+ ? { geminiStream: new URLSearchParams(search).get('alt') === 'sse' ? 'sse' : 'json-array' }
80
+ : {}),
81
+ };
82
+ }
83
+ function withoutHopByHop(headers, keep) {
84
+ return Object.fromEntries(Object.entries(headers).filter(([name]) => !HOP_BY_HOP.has(name) || name === keep));
85
+ }
86
+ /**
87
+ * Sends the request upstream and pipes the response back as it arrives, without changing either.
88
+ *
89
+ * @param params.req The client request (its method and headers are forwarded)
90
+ * @param params.res The client response; upstream status, headers and body are written to it
91
+ * @param params.body The request body exactly as received
92
+ * @param params.route Where to send it
93
+ * @returns Resolves once the response has ended, failed, or the client has gone away
94
+ */
95
+ export function forwardRequest(params) {
96
+ const { req, res, body, route } = params;
97
+ const started = Date.now();
98
+ const headers = withoutHopByHop(req.headers);
99
+ if (body.length > 0)
100
+ headers['content-length'] = body.length;
101
+ const transport = route.url.protocol === 'https:' ? https : http;
102
+ return new Promise(resolve => {
103
+ let settled = false;
104
+ let status = 0;
105
+ let bytesOut = 0;
106
+ const settle = (outcome = {}) => {
107
+ if (settled)
108
+ return;
109
+ settled = true;
110
+ resolve({ status, durationMs: Date.now() - started, bytesOut, clientAborted: false, ...outcome });
111
+ };
112
+ const upstreamReq = transport.request(route.url, { method: req.method, headers }, upstreamRes => {
113
+ status = upstreamRes.statusCode ?? 502;
114
+ // content-length stays: the bytes pass through unchanged, so it is still correct.
115
+ res.writeHead(status, withoutHopByHop(upstreamRes.headers, 'content-length'));
116
+ upstreamRes.on('data', (chunk) => { bytesOut += chunk.length; });
117
+ upstreamRes.pipe(res);
118
+ upstreamRes.on('end', () => settle());
119
+ // The upstream dropped mid-stream, or was destroyed after the client left. pipe() does not
120
+ // pass source errors on, and an unhandled one would crash the gate.
121
+ upstreamRes.on('error', err => {
122
+ settle({ error: err.message });
123
+ res.destroy(err);
124
+ });
125
+ });
126
+ upstreamReq.on('error', err => {
127
+ // A dual-stack host refusing every address raises an AggregateError with an empty message.
128
+ const detail = err instanceof AggregateError ? err.errors.map(String).join('; ') : err.message;
129
+ settle({ status: 502, error: detail });
130
+ if (res.destroyed)
131
+ return;
132
+ if (res.headersSent) {
133
+ res.destroy(err);
134
+ return;
135
+ }
136
+ res.writeHead(502, { 'content-type': 'application/json' });
137
+ res.end(JSON.stringify({
138
+ error: { type: 'slm_gate_upstream_unreachable', message: `slm-gate could not reach ${route.url.origin}: ${detail}` },
139
+ }));
140
+ });
141
+ // The tool gave up (Esc, its own timeout): stop the upstream generation too.
142
+ res.on('close', () => {
143
+ if (res.writableFinished)
144
+ return;
145
+ settle({ clientAborted: true });
146
+ upstreamReq.destroy();
147
+ });
148
+ upstreamReq.end(body);
149
+ });
150
+ }
@@ -0,0 +1,40 @@
1
+ import { requestListener, server } from './server.js';
2
+ import { listenOnThisComputer } from '../utils/local-only.js';
3
+ import { CONFIG } from '../config.js';
4
+ import { installLangfuseFlushLifecycle } from '../ledger/flush-lifecycle.js';
5
+ import { getDb, logLedgerInfo } from '../ledger/index.js';
6
+ import { isEntryPoint } from '../utils/entry-point.js';
7
+ import { warmUpLocalModel } from './distill.js';
8
+ import { warmUpAnsweringModel } from './local-first.js';
9
+ // Ledger writes are synchronous. With better-sqlite3's default 5 s busy wait, another process
10
+ // holding the ledger's write lock would freeze every stream through the gate. Wait briefly instead;
11
+ // a row that still cannot be written is logged and dropped (see recordRequest in server.ts).
12
+ const LEDGER_BUSY_TIMEOUT_MS = 100;
13
+ /**
14
+ * Entry point for the `llm-gate` layer.
15
+ *
16
+ * Can be imported as a module (`export { server }`) for integration testing,
17
+ * or executed directly via CLI to boot the standalone HTTP proxy server.
18
+ */
19
+ if (isEntryPoint(import.meta.url)) {
20
+ const sinks = ['sqlite'];
21
+ if (CONFIG.LANGFUSE_PUBLIC_KEY && CONFIG.LANGFUSE_SECRET_KEY && CONFIG.LANGFUSE_HOST) {
22
+ sinks.push('langfuse');
23
+ }
24
+ logLedgerInfo('llm-gate');
25
+ getDb().pragma(`busy_timeout = ${LEDGER_BUSY_TIMEOUT_MS}`);
26
+ if (CONFIG.LLM_GATE_DISTILL)
27
+ warmUpLocalModel();
28
+ if (CONFIG.LLM_GATE_LOCAL_FIRST)
29
+ warmUpAnsweringModel();
30
+ listenOnThisComputer({
31
+ handler: requestListener,
32
+ port: CONFIG.LLM_GATE_PORT,
33
+ onListening: () => console.error(`LLM Gate running on port ${CONFIG.LLM_GATE_PORT}, for programs on this computer only (pass-through: anthropic, chat-completions, responses, gemini). sinks: [${sinks.join(', ')}]`),
34
+ }).catch(err => {
35
+ console.error(`LLM Gate could not listen on port ${CONFIG.LLM_GATE_PORT}: ${err instanceof Error ? err.message : String(err)}`);
36
+ process.exit(1);
37
+ });
38
+ installLangfuseFlushLifecycle('llm-gate');
39
+ }
40
+ export { server };