sandoichi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +25 -0
- package/index.mjs +54 -0
- package/package.json +27 -0
- package/src/active-session.mjs +136 -0
- package/src/cache-attribution.mjs +182 -0
- package/src/context-transform.mjs +492 -0
- package/src/core.mjs +258 -0
- package/src/history-budget.mjs +36 -0
- package/src/history-dedupe.mjs +98 -0
- package/src/history-shake.mjs +66 -0
- package/src/history-structure.mjs +28 -0
- package/src/hook-cli.mjs +118 -0
- package/src/mcp-server.mjs +45 -0
- package/src/metrics-cli.mjs +34 -0
- package/src/metrics.mjs +390 -0
- package/src/provider-ledger.mjs +138 -0
- package/src/provider-usage.mjs +224 -0
- package/src/proxy-metrics.mjs +34 -0
- package/src/proxy.mjs +212 -0
- package/src/routing.mjs +89 -0
- package/src/secret-redaction.mjs +21 -0
- package/src/semantic-compactor.mjs +218 -0
- package/src/statusline.mjs +100 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const SCHEMA = 'sando-provider-usage/v1';
|
|
7
|
+
const VERSION = 1;
|
|
8
|
+
const LOCK_WAIT_MS = 10;
|
|
9
|
+
const LOCK_ATTEMPTS = 250;
|
|
10
|
+
const STALE_LOCK_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
export const PROVIDER_USAGE_SCHEMA = SCHEMA;
|
|
13
|
+
export const PROVIDER_USAGE_VERSION = VERSION;
|
|
14
|
+
|
|
15
|
+
function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
16
|
+
function text(value) { return typeof value === 'string' && value.length > 0; }
|
|
17
|
+
function counter(value) { return Number.isSafeInteger(value) && value >= 0; }
|
|
18
|
+
function safeSum(...values) {
|
|
19
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
20
|
+
return Number.isSafeInteger(total) ? total : null;
|
|
21
|
+
}
|
|
22
|
+
function sha256(value) { return `sha256:${createHash('sha256').update(value).digest('hex')}`; }
|
|
23
|
+
function isoDate(value, fallback = new Date()) {
|
|
24
|
+
const date = value === undefined ? new Date(fallback) : new Date(value);
|
|
25
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
26
|
+
return date.toISOString();
|
|
27
|
+
}
|
|
28
|
+
function optionalCounter(value) { return value === undefined ? 0 : counter(value) ? value : null; }
|
|
29
|
+
function jsonLines(textValue) {
|
|
30
|
+
if (typeof textValue !== 'string') throw new TypeError('transcript must be text');
|
|
31
|
+
return textValue.split(/\r?\n/).flatMap((line) => {
|
|
32
|
+
if (!line.trim()) return [];
|
|
33
|
+
try {
|
|
34
|
+
const value = JSON.parse(line);
|
|
35
|
+
return record(value) ? [{ value, line }] : [];
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function usageRecord({ host, source, sourceKey, sessionId, turnId, at, inputTokens, cachedInputTokens = 0,
|
|
43
|
+
cacheWriteInputTokens = 0, outputTokens, reasoningOutputTokens = 0 }) {
|
|
44
|
+
if (!text(host) || !text(source) || !text(sourceKey)
|
|
45
|
+
|| (sessionId !== null && !text(sessionId)) || (turnId !== null && !text(turnId))
|
|
46
|
+
|| !text(at) || !counter(inputTokens) || !counter(cachedInputTokens)
|
|
47
|
+
|| !counter(cacheWriteInputTokens) || !counter(outputTokens) || !counter(reasoningOutputTokens)) return null;
|
|
48
|
+
const totalTokens = safeSum(inputTokens, outputTokens);
|
|
49
|
+
if (totalTokens === null) return null;
|
|
50
|
+
const identity = JSON.stringify({ host, source, sourceKey, at, inputTokens, cachedInputTokens,
|
|
51
|
+
cacheWriteInputTokens, outputTokens, reasoningOutputTokens, totalTokens });
|
|
52
|
+
return {
|
|
53
|
+
eventKey: `usage:${host}:${sha256(identity)}`,
|
|
54
|
+
schema: SCHEMA, version: VERSION, host, source,
|
|
55
|
+
sessionId: sessionId ?? null, turnId: turnId ?? null, at,
|
|
56
|
+
inputTokens, cachedInputTokens, cacheWriteInputTokens, outputTokens,
|
|
57
|
+
reasoningOutputTokens, totalTokens,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function claudeRecord(value, index, { sessionId = null, turnId = null, now } = {}) {
|
|
62
|
+
if (value.type !== 'assistant' || !record(value.message?.usage)) return null;
|
|
63
|
+
const usage = value.message.usage;
|
|
64
|
+
const inputTokens = usage.input_tokens;
|
|
65
|
+
const cachedInputTokens = optionalCounter(usage.cache_read_input_tokens);
|
|
66
|
+
const cacheWriteInputTokens = optionalCounter(usage.cache_creation_input_tokens);
|
|
67
|
+
const outputTokens = usage.output_tokens;
|
|
68
|
+
if (!counter(inputTokens) || cachedInputTokens === null || cacheWriteInputTokens === null || !counter(outputTokens)) return null;
|
|
69
|
+
const totalInputTokens = safeSum(inputTokens, cachedInputTokens, cacheWriteInputTokens);
|
|
70
|
+
if (totalInputTokens === null) return null;
|
|
71
|
+
const totalTokens = usage.total_tokens === undefined ? safeSum(totalInputTokens, outputTokens) : usage.total_tokens;
|
|
72
|
+
if (!counter(totalTokens) || totalTokens !== totalInputTokens + outputTokens) return null;
|
|
73
|
+
return usageRecord({
|
|
74
|
+
host: 'claude', source: 'claude-transcript', sourceKey: value.uuid ?? value.request_id ?? value.timestamp ?? String(index),
|
|
75
|
+
sessionId, turnId: value.turn_id ?? turnId, at: isoDate(value.timestamp, now),
|
|
76
|
+
inputTokens: totalInputTokens, cachedInputTokens, cacheWriteInputTokens, outputTokens,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function codexRecord(value, index, { sessionId = null, turnId = null, now } = {}) {
|
|
81
|
+
const usage = value.type === 'turn.completed'
|
|
82
|
+
? value.usage
|
|
83
|
+
: value.type === 'event_msg' && value.payload?.type === 'token_count'
|
|
84
|
+
? value.payload.info?.last_token_usage ?? value.payload.info?.usage
|
|
85
|
+
: undefined;
|
|
86
|
+
if (!record(usage)) return null;
|
|
87
|
+
const inputTokens = usage.input_tokens;
|
|
88
|
+
const cachedInputTokens = optionalCounter(usage.cached_input_tokens ?? usage.cache_read_input_tokens);
|
|
89
|
+
const cacheWriteInputTokens = optionalCounter(usage.cache_write_input_tokens);
|
|
90
|
+
const outputTokens = usage.output_tokens;
|
|
91
|
+
const reasoningOutputTokens = optionalCounter(usage.reasoning_output_tokens);
|
|
92
|
+
if (!counter(inputTokens) || cachedInputTokens === null || cacheWriteInputTokens === null
|
|
93
|
+
|| !counter(outputTokens) || reasoningOutputTokens === null) return null;
|
|
94
|
+
const totalTokens = usage.total_tokens === undefined ? safeSum(inputTokens, outputTokens) : usage.total_tokens;
|
|
95
|
+
if (!counter(totalTokens) || totalTokens !== inputTokens + outputTokens) return null;
|
|
96
|
+
return usageRecord({
|
|
97
|
+
host: 'codex', source: 'codex-transcript', sourceKey: value.turn_id ?? value.id ?? value.timestamp ?? String(index),
|
|
98
|
+
sessionId, turnId: value.turn_id ?? turnId, at: isoDate(value.timestamp, now),
|
|
99
|
+
inputTokens, cachedInputTokens, cacheWriteInputTokens, outputTokens, reasoningOutputTokens,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function parseClaudeTranscript(textValue, options = {}) {
|
|
104
|
+
return jsonLines(textValue).map(({ value }, index) => claudeRecord(value, index, options)).filter(Boolean);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseCodexTranscript(textValue, options = {}) {
|
|
108
|
+
return jsonLines(textValue).map(({ value }, index) => codexRecord(value, index, options)).filter(Boolean);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function defaultProviderUsagePath(env = process.env) {
|
|
112
|
+
const configured = env.SANDO_PROVIDER_USAGE_PATH;
|
|
113
|
+
if (configured !== undefined) {
|
|
114
|
+
if (typeof configured !== 'string' || !path.isAbsolute(configured)) throw new Error('provider usage path must be absolute');
|
|
115
|
+
return configured;
|
|
116
|
+
}
|
|
117
|
+
const stateHome = env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
|
|
118
|
+
if (!path.isAbsolute(stateHome)) throw new Error('state directory must be absolute');
|
|
119
|
+
return path.join(stateHome, 'sando', 'provider-usage.json');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function timezone() { return new Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; }
|
|
123
|
+
function emptyState() { return { schema: SCHEMA, version: VERSION, timezone: timezone(), records: [] }; }
|
|
124
|
+
function resolvePath(storagePath) {
|
|
125
|
+
if (typeof storagePath !== 'string' || !path.isAbsolute(storagePath)) throw new Error('provider usage path must be absolute');
|
|
126
|
+
return storagePath;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function validateUsage(value) {
|
|
130
|
+
if (!record(value) || value.schema !== SCHEMA || value.version !== VERSION || !text(value.eventKey)
|
|
131
|
+
|| !text(value.host) || !text(value.source) || !text(value.at)
|
|
132
|
+
|| (value.sessionId !== null && !text(value.sessionId)) || (value.turnId !== null && !text(value.turnId))
|
|
133
|
+
|| !counter(value.inputTokens) || !counter(value.cachedInputTokens) || !counter(value.cacheWriteInputTokens)
|
|
134
|
+
|| !counter(value.outputTokens) || !counter(value.reasoningOutputTokens) || !counter(value.totalTokens)
|
|
135
|
+
|| value.totalTokens !== value.inputTokens + value.outputTokens) throw new Error('provider usage record is invalid');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validateState(value) {
|
|
139
|
+
if (!record(value) || value.schema !== SCHEMA || value.version !== VERSION || !Array.isArray(value.records)) {
|
|
140
|
+
throw new Error('provider usage state is invalid');
|
|
141
|
+
}
|
|
142
|
+
const keys = new Set();
|
|
143
|
+
for (const item of value.records) {
|
|
144
|
+
validateUsage(item);
|
|
145
|
+
if (keys.has(item.eventKey)) throw new Error('provider usage state contains duplicate events');
|
|
146
|
+
keys.add(item.eventKey);
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ensureDirectory(directory) {
|
|
152
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
153
|
+
const stat = fs.lstatSync(directory);
|
|
154
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('provider usage directory is unsafe');
|
|
155
|
+
fs.chmodSync(directory, 0o700);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function waitForLock() { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_WAIT_MS); }
|
|
159
|
+
function withLock(lockPath, operation) {
|
|
160
|
+
let handle;
|
|
161
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
|
|
162
|
+
try { handle = fs.openSync(lockPath, 'wx', 0o600); break; }
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
165
|
+
const stat = fs.statSync(lockPath, { throwIfNoEntry: false });
|
|
166
|
+
if (stat && Date.now() - stat.mtimeMs > STALE_LOCK_MS) fs.rmSync(lockPath, { force: true });
|
|
167
|
+
else waitForLock();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (handle === undefined) throw new Error('provider usage lock timeout');
|
|
171
|
+
try { return operation(); } finally { fs.closeSync(handle); fs.rmSync(lockPath, { force: true }); }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function atomicWrite(filePath, value) {
|
|
175
|
+
const temporary = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.tmp`);
|
|
176
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
177
|
+
try { fs.renameSync(temporary, filePath); } catch (error) { fs.rmSync(temporary, { force: true }); throw error; }
|
|
178
|
+
fs.chmodSync(filePath, 0o600);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function readProviderUsage(storagePath = defaultProviderUsagePath()) {
|
|
182
|
+
const filePath = resolvePath(storagePath);
|
|
183
|
+
if (!fs.existsSync(filePath)) return emptyState();
|
|
184
|
+
return validateState(JSON.parse(fs.readFileSync(filePath, 'utf8')));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function appendProviderUsage({ storagePath = defaultProviderUsagePath(), records = [] } = {}) {
|
|
188
|
+
if (!Array.isArray(records)) throw new TypeError('provider usage records must be an array');
|
|
189
|
+
for (const item of records) validateUsage(item);
|
|
190
|
+
const filePath = resolvePath(storagePath);
|
|
191
|
+
ensureDirectory(path.dirname(filePath));
|
|
192
|
+
return withLock(`${filePath}.lock`, () => {
|
|
193
|
+
const state = readProviderUsage(filePath);
|
|
194
|
+
const existing = new Set(state.records.map((item) => item.eventKey));
|
|
195
|
+
for (const item of records) if (!existing.has(item.eventKey)) { state.records.push(item); existing.add(item.eventKey); }
|
|
196
|
+
atomicWrite(filePath, state);
|
|
197
|
+
return state;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function collectProviderUsage({ host, transcriptPath, sessionId = null, turnId = null,
|
|
202
|
+
storagePath = defaultProviderUsagePath(), now } = {}) {
|
|
203
|
+
if (!['claude', 'codex'].includes(host) || typeof transcriptPath !== 'string' || !transcriptPath) return { records: [], state: readProviderUsage(storagePath) };
|
|
204
|
+
try {
|
|
205
|
+
const textValue = fs.readFileSync(transcriptPath, 'utf8');
|
|
206
|
+
const parse = host === 'claude' ? parseClaudeTranscript : parseCodexTranscript;
|
|
207
|
+
const records = parse(textValue, { sessionId, turnId, now });
|
|
208
|
+
return { records, state: appendProviderUsage({ storagePath, records }) };
|
|
209
|
+
} catch {
|
|
210
|
+
return { records: [], state: readProviderUsage(storagePath) };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function buildProviderUsageReport(state, { sessionId } = {}) {
|
|
215
|
+
const records = validateState(state).records.filter((item) => sessionId === undefined || item.sessionId === sessionId);
|
|
216
|
+
const sessions = new Set(records.map((item) => `${item.host}\0${item.sessionId ?? '<unknown>'}`));
|
|
217
|
+
const sum = (field) => records.reduce((total, item) => total + item[field], 0);
|
|
218
|
+
return {
|
|
219
|
+
eventCount: records.length, sessionCount: sessions.size,
|
|
220
|
+
inputTokens: sum('inputTokens'), cachedInputTokens: sum('cachedInputTokens'),
|
|
221
|
+
cacheWriteInputTokens: sum('cacheWriteInputTokens'), outputTokens: sum('outputTokens'),
|
|
222
|
+
reasoningOutputTokens: sum('reasoningOutputTokens'), totalTokens: sum('totalTokens'),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const SCHEMA = 'sando-proxy-metrics/v1';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Durable per-request record of what the proxy did and what the provider actually
|
|
9
|
+
* billed, so a real median savings figure can be computed later from real usage
|
|
10
|
+
* instead of a single live probe. Append-only JSONL: one line per forwarded request,
|
|
11
|
+
* safe to `tail -f` or fold with a small aggregation script.
|
|
12
|
+
*/
|
|
13
|
+
export function defaultProxyMetricsPath(env = process.env) {
|
|
14
|
+
const configured = env.SANDO_PROXY_METRICS_PATH;
|
|
15
|
+
if (configured !== undefined) return configured;
|
|
16
|
+
const stateHome = env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
|
|
17
|
+
return path.join(stateHome, 'sando', 'proxy-requests.jsonl');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function recordProxyRequest({ storagePath, provider, model, stats, usage, now = new Date() } = {}) {
|
|
21
|
+
if (typeof storagePath !== 'string' || !storagePath) throw new TypeError('storagePath is required');
|
|
22
|
+
if (typeof provider !== 'string' || !provider) throw new TypeError('provider is required');
|
|
23
|
+
fs.mkdirSync(path.dirname(storagePath), { recursive: true, mode: 0o700 });
|
|
24
|
+
const record = {
|
|
25
|
+
schema: SCHEMA,
|
|
26
|
+
at: now.toISOString(),
|
|
27
|
+
provider,
|
|
28
|
+
model: typeof model === 'string' ? model : null,
|
|
29
|
+
stats: stats ?? null,
|
|
30
|
+
usage: usage ?? null,
|
|
31
|
+
};
|
|
32
|
+
fs.appendFileSync(storagePath, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
33
|
+
return record;
|
|
34
|
+
}
|
package/src/proxy.mjs
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
|
|
3
|
+
import { detectProviderBody, listSemanticCandidates, transformProviderRequest } from './context-transform.mjs';
|
|
4
|
+
import { recordProxyRequest } from './proxy-metrics.mjs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
8
|
+
'connection', 'content-length', 'keep-alive', 'proxy-authenticate',
|
|
9
|
+
'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host',
|
|
10
|
+
'accept-encoding', 'content-encoding',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function assertUpstream(value) {
|
|
14
|
+
let url;
|
|
15
|
+
try { url = new URL(value); } catch { throw new TypeError('upstream must be an absolute URL'); }
|
|
16
|
+
if (!['http:', 'https:'].includes(url.protocol)) throw new TypeError('upstream must use http or https');
|
|
17
|
+
if (url.username || url.password) throw new TypeError('upstream must not contain credentials');
|
|
18
|
+
return url;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readBody(request, maxBytes) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let bytes = 0;
|
|
25
|
+
request.on('data', (chunk) => {
|
|
26
|
+
bytes += chunk.length;
|
|
27
|
+
if (bytes > maxBytes) {
|
|
28
|
+
reject(new Error('request body exceeds proxy limit'));
|
|
29
|
+
request.destroy();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
chunks.push(chunk);
|
|
33
|
+
});
|
|
34
|
+
request.on('end', () => resolve(Buffer.concat(chunks)));
|
|
35
|
+
request.on('error', reject);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function targetUrl(upstream, requestUrl) {
|
|
40
|
+
const request = new URL(requestUrl, 'http://sando.invalid');
|
|
41
|
+
const target = new URL(upstream);
|
|
42
|
+
const basePath = target.pathname.replace(/\/$/, '');
|
|
43
|
+
target.pathname = `${basePath}${request.pathname}` || '/';
|
|
44
|
+
target.search = request.search;
|
|
45
|
+
return target;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function forwardedHeaders(request) {
|
|
49
|
+
const headers = new Headers();
|
|
50
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
51
|
+
if (HOP_BY_HOP_HEADERS.has(name.toLowerCase()) || value === undefined) continue;
|
|
52
|
+
headers.set(name, Array.isArray(value) ? value.join(', ') : value);
|
|
53
|
+
}
|
|
54
|
+
headers.set('accept-encoding', 'identity');
|
|
55
|
+
return headers;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function responseHeaders(response) {
|
|
59
|
+
const headers = {};
|
|
60
|
+
response.headers.forEach((value, name) => {
|
|
61
|
+
if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase())) headers[name] = value;
|
|
62
|
+
});
|
|
63
|
+
return headers;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const MAX_USAGE_SCAN_BYTES = 2 * 1024 * 1024;
|
|
67
|
+
|
|
68
|
+
/** Merges every `"usage":{...}` object seen across a (possibly streamed) Anthropic
|
|
69
|
+
* response: `message_start` carries input/cache token counts, `message_delta`
|
|
70
|
+
* carries the final output count, and later objects overwrite matching keys. */
|
|
71
|
+
function extractUsage(text) {
|
|
72
|
+
let usage = null;
|
|
73
|
+
for (const match of text.matchAll(/"usage":\s*(\{[^{}]*\})/g)) {
|
|
74
|
+
try { usage = { ...usage, ...JSON.parse(match[1]) }; } catch { /* ignore malformed fragment */ }
|
|
75
|
+
}
|
|
76
|
+
return usage;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function pipeResponse(response, outgoing, onText) {
|
|
80
|
+
outgoing.writeHead(response.status, response.statusText, responseHeaders(response));
|
|
81
|
+
if (!response.body) {
|
|
82
|
+
outgoing.end();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const decoder = new TextDecoder();
|
|
86
|
+
let scanned = 0;
|
|
87
|
+
for await (const chunk of response.body) {
|
|
88
|
+
outgoing.write(chunk);
|
|
89
|
+
if (onText && scanned < MAX_USAGE_SCAN_BYTES) {
|
|
90
|
+
scanned += chunk.length;
|
|
91
|
+
onText(decoder.decode(chunk, { stream: true }));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
outgoing.end();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function jsonResponse(outgoing, status, body) {
|
|
98
|
+
const text = JSON.stringify(body);
|
|
99
|
+
outgoing.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(text) });
|
|
100
|
+
outgoing.end(text);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createSemanticStats(candidates) {
|
|
104
|
+
return {
|
|
105
|
+
candidates: candidates.length,
|
|
106
|
+
attempted: candidates.length,
|
|
107
|
+
accepted: 0,
|
|
108
|
+
cacheHits: 0,
|
|
109
|
+
fallbacks: 0,
|
|
110
|
+
skipped: 0,
|
|
111
|
+
netSavedTokens: 0,
|
|
112
|
+
pending: candidates.length,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function observeSemanticCandidates({ provider, candidates, semanticCompactor, stats }) {
|
|
117
|
+
for (const candidate of candidates) {
|
|
118
|
+
try {
|
|
119
|
+
const result = await semanticCompactor({ provider, ...candidate });
|
|
120
|
+
if (result?.status === 'candidate') stats.accepted += 1;
|
|
121
|
+
else if (result?.status === 'fallback') stats.fallbacks += 1;
|
|
122
|
+
else stats.skipped += 1;
|
|
123
|
+
if (result?.cacheHit === true) stats.cacheHits += 1;
|
|
124
|
+
if (Number.isSafeInteger(result?.netSavedTokens)) stats.netSavedTokens += result.netSavedTokens;
|
|
125
|
+
} catch {
|
|
126
|
+
stats.fallbacks += 1;
|
|
127
|
+
} finally {
|
|
128
|
+
stats.pending -= 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function createProviderProxy({ upstream, host = '127.0.0.1', port = 0, policy = {}, maxBodyBytes = DEFAULT_MAX_BODY_BYTES, semanticCompactor, metricsPath } = {}) {
|
|
134
|
+
const upstreamUrl = assertUpstream(upstream);
|
|
135
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new TypeError('port is invalid');
|
|
136
|
+
if (!Number.isInteger(maxBodyBytes) || maxBodyBytes < 1024) throw new TypeError('maxBodyBytes is invalid');
|
|
137
|
+
let lastStats = null;
|
|
138
|
+
let lastRequestAt = null;
|
|
139
|
+
|
|
140
|
+
const server = http.createServer(async (request, outgoing) => {
|
|
141
|
+
try {
|
|
142
|
+
if (request.method === 'GET' && new URL(request.url, 'http://sando.invalid').pathname === '/health') {
|
|
143
|
+
jsonResponse(outgoing, 200, { schema: 'sando-provider-proxy/v1', status: 'ok', lastStats });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const rawBody = await readBody(request, maxBodyBytes);
|
|
147
|
+
let body = rawBody;
|
|
148
|
+
let recordProvider = null;
|
|
149
|
+
let recordModel = null;
|
|
150
|
+
let recordStats = null;
|
|
151
|
+
if (rawBody.length > 0 && /application\/json/i.test(request.headers['content-type'] ?? '')) {
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(rawBody.toString('utf8'));
|
|
154
|
+
const provider = detectProviderBody(parsed, request.headers);
|
|
155
|
+
if (provider) {
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
const idleMs = lastRequestAt === null ? null : now - lastRequestAt;
|
|
158
|
+
lastRequestAt = now;
|
|
159
|
+
const transformed = transformProviderRequest({ provider, body: parsed, policy, idleMs });
|
|
160
|
+
if (transformed.changed) body = Buffer.from(JSON.stringify(transformed.body));
|
|
161
|
+
lastStats = { provider, ...transformed.stats, changed: transformed.changed, reasons: transformed.reasons };
|
|
162
|
+
recordProvider = provider;
|
|
163
|
+
recordModel = typeof parsed?.model === 'string' ? parsed.model : null;
|
|
164
|
+
recordStats = transformed.stats;
|
|
165
|
+
if (typeof semanticCompactor === 'function') {
|
|
166
|
+
const candidates = listSemanticCandidates({ provider, body: transformed.body });
|
|
167
|
+
const stats = createSemanticStats(candidates);
|
|
168
|
+
lastStats.semantic = stats;
|
|
169
|
+
setImmediate(() => observeSemanticCandidates({ provider, candidates, semanticCompactor, stats }));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
body = rawBody;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const response = await fetch(targetUrl(upstreamUrl, request.url), {
|
|
177
|
+
method: request.method,
|
|
178
|
+
headers: forwardedHeaders(request),
|
|
179
|
+
body: ['GET', 'HEAD'].includes(request.method) ? undefined : body,
|
|
180
|
+
redirect: 'manual',
|
|
181
|
+
});
|
|
182
|
+
let responseText = '';
|
|
183
|
+
await pipeResponse(response, outgoing, metricsPath ? (chunk) => { responseText += chunk; } : undefined);
|
|
184
|
+
if (metricsPath && recordProvider) {
|
|
185
|
+
try {
|
|
186
|
+
recordProxyRequest({
|
|
187
|
+
storagePath: metricsPath, provider: recordProvider, model: recordModel,
|
|
188
|
+
stats: recordStats, usage: extractUsage(responseText),
|
|
189
|
+
});
|
|
190
|
+
} catch { /* metrics are best-effort and must never affect the proxied response */ }
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (!outgoing.headersSent) jsonResponse(outgoing, error.message === 'request body exceeds proxy limit' ? 413 : 502, { error: 'sando proxy upstream failure' });
|
|
194
|
+
else outgoing.destroy();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await new Promise((resolve, reject) => {
|
|
199
|
+
server.once('error', reject);
|
|
200
|
+
server.listen(port, host, resolve);
|
|
201
|
+
});
|
|
202
|
+
const address = server.address();
|
|
203
|
+
const actualPort = typeof address === 'object' && address ? address.port : port;
|
|
204
|
+
return {
|
|
205
|
+
server,
|
|
206
|
+
host,
|
|
207
|
+
port: actualPort,
|
|
208
|
+
url: `http://${host}:${actualPort}`,
|
|
209
|
+
get lastStats() { return lastStats; },
|
|
210
|
+
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
|
|
211
|
+
};
|
|
212
|
+
}
|
package/src/routing.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export const ROUTING_POLICY_VERSION = 'sando-routing/v1';
|
|
2
|
+
|
|
3
|
+
const READ_SUMMARY_LIMITS = Object.freeze({
|
|
4
|
+
minTotalLines: 100,
|
|
5
|
+
maxSummaryBytes: 2 * 1024 * 1024,
|
|
6
|
+
maxSummaryLines: 20_000,
|
|
7
|
+
});
|
|
8
|
+
const GREP_LIMITS = Object.freeze({
|
|
9
|
+
files: 20,
|
|
10
|
+
matchesPerFile: 20,
|
|
11
|
+
singleFileMatches: 200,
|
|
12
|
+
internalTotalMatches: 2_000,
|
|
13
|
+
nativeMaxFileBytes: 4 * 1024 * 1024,
|
|
14
|
+
timeoutMs: 30_000,
|
|
15
|
+
maxColumns: 512,
|
|
16
|
+
});
|
|
17
|
+
const OUTPUT_LIMITS = Object.freeze({
|
|
18
|
+
spillBytes: 50 * 1024,
|
|
19
|
+
headBytes: 20 * 1024,
|
|
20
|
+
tailBytes: 20 * 1024,
|
|
21
|
+
tailLines: 500,
|
|
22
|
+
maxColumns: 768,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const isNonNegativeSafeInteger = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
26
|
+
|
|
27
|
+
export function planToolRoute({
|
|
28
|
+
toolName,
|
|
29
|
+
selector = false,
|
|
30
|
+
raw = false,
|
|
31
|
+
lineCount = 0,
|
|
32
|
+
fileBytes,
|
|
33
|
+
prose = false,
|
|
34
|
+
summarizeProse = false,
|
|
35
|
+
summarizeEnabled = true,
|
|
36
|
+
grepScope = 'multi',
|
|
37
|
+
outputBytes = 0,
|
|
38
|
+
} = {}) {
|
|
39
|
+
const name = typeof toolName === 'string' ? toolName.toLowerCase() : toolName;
|
|
40
|
+
const readCanSummarize = name === 'read'
|
|
41
|
+
&& !selector
|
|
42
|
+
&& !raw
|
|
43
|
+
&& summarizeEnabled
|
|
44
|
+
&& (!prose || summarizeProse)
|
|
45
|
+
&& isNonNegativeSafeInteger(lineCount)
|
|
46
|
+
&& lineCount >= READ_SUMMARY_LIMITS.minTotalLines
|
|
47
|
+
&& lineCount <= READ_SUMMARY_LIMITS.maxSummaryLines
|
|
48
|
+
&& isNonNegativeSafeInteger(fileBytes)
|
|
49
|
+
&& fileBytes > 0
|
|
50
|
+
&& fileBytes <= READ_SUMMARY_LIMITS.maxSummaryBytes;
|
|
51
|
+
|
|
52
|
+
if (readCanSummarize) {
|
|
53
|
+
return {
|
|
54
|
+
route: 'summary',
|
|
55
|
+
modelVisible: 'elided-structure',
|
|
56
|
+
source: 'sando-read-summarize',
|
|
57
|
+
limits: READ_SUMMARY_LIMITS,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (name === 'grep') {
|
|
62
|
+
return {
|
|
63
|
+
route: 'structured',
|
|
64
|
+
modelVisible: 'bounded-matches',
|
|
65
|
+
source: 'sando-grep',
|
|
66
|
+
limits: Object.freeze({
|
|
67
|
+
...GREP_LIMITS,
|
|
68
|
+
matchesPerFile: grepScope === 'single-file'
|
|
69
|
+
? GREP_LIMITS.singleFileMatches
|
|
70
|
+
: GREP_LIMITS.matchesPerFile,
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (name === 'bash' && isNonNegativeSafeInteger(outputBytes) && outputBytes > OUTPUT_LIMITS.spillBytes) {
|
|
76
|
+
return {
|
|
77
|
+
route: 'artifact',
|
|
78
|
+
modelVisible: 'head-tail-artifact-ref',
|
|
79
|
+
source: 'sando-output-meta',
|
|
80
|
+
limits: OUTPUT_LIMITS,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
route: 'passthrough',
|
|
86
|
+
modelVisible: 'bounded-output',
|
|
87
|
+
source: 'spike-default',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function redact(text) {
|
|
2
|
+
let count = 0;
|
|
3
|
+
const replace = (pattern, replacement) => {
|
|
4
|
+
text = text.replace(pattern, (...args) => {
|
|
5
|
+
count += 1;
|
|
6
|
+
return typeof replacement === 'function' ? replacement(...args) : replacement;
|
|
7
|
+
});
|
|
8
|
+
};
|
|
9
|
+
replace(/-----BEGIN [A-Z ]+ KEY-----[\s\S]*?-----END [A-Z ]+ KEY-----/g, '[REDACTED PRIVATE KEY]');
|
|
10
|
+
replace(/\b(?:sk|rk)-[A-Za-z0-9_-]{12,}\b/g, '[REDACTED TOKEN]');
|
|
11
|
+
replace(/\bgh[pousr]_[A-Za-z0-9_-]{12,}\b/g, '[REDACTED TOKEN]');
|
|
12
|
+
replace(/\bgithub_pat_[A-Za-z0-9_-]{20,}\b/g, '[REDACTED TOKEN]');
|
|
13
|
+
replace(/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED TOKEN]');
|
|
14
|
+
replace(/(authorization\s*[:=]\s*(?:bearer\s+)?)[^\s,"'}]+/gi, (_match, prefix) => `${prefix}[REDACTED]`);
|
|
15
|
+
replace(/(["']?(?:api[_-]?key|access[_-]?token|password|secret|private[_-]?key)["']?\s*[:=]\s*["']?)[^\s,"'}]+/gi, (_match, prefix) => `${prefix}[REDACTED]`);
|
|
16
|
+
return { text, count };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function hasSecret(text) {
|
|
20
|
+
return /(?:authorization|api[_-]?key|access[_-]?token|password|secret|private[_-]?key)\s*[:=]\s*(?!\[REDACTED\])\S+|-----BEGIN [A-Z ]+ KEY-----|\b(?:sk|rk)-[A-Za-z0-9_-]{12,}\b|\bgh[pousr]_[A-Za-z0-9_-]{12,}\b|\bgithub_pat_[A-Za-z0-9_-]{20,}\b|\bAKIA[0-9A-Z]{16}\b/i.test(text);
|
|
21
|
+
}
|