@vidiemme/copilot-proxy 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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/dist/attribution.d.ts +34 -0
  4. package/dist/attribution.js +118 -0
  5. package/dist/attribution.js.map +1 -0
  6. package/dist/autostart.d.ts +10 -0
  7. package/dist/autostart.js +119 -0
  8. package/dist/autostart.js.map +1 -0
  9. package/dist/cli.d.ts +2 -0
  10. package/dist/cli.js +393 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/collector/forwarder.d.ts +44 -0
  13. package/dist/collector/forwarder.js +203 -0
  14. package/dist/collector/forwarder.js.map +1 -0
  15. package/dist/config.d.ts +44 -0
  16. package/dist/config.js +105 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/defaults.d.ts +33 -0
  19. package/dist/defaults.js +35 -0
  20. package/dist/defaults.js.map +1 -0
  21. package/dist/gitRepository.d.ts +21 -0
  22. package/dist/gitRepository.js +147 -0
  23. package/dist/gitRepository.js.map +1 -0
  24. package/dist/paths.d.ts +12 -0
  25. package/dist/paths.js +39 -0
  26. package/dist/paths.js.map +1 -0
  27. package/dist/projectDetector.d.ts +48 -0
  28. package/dist/projectDetector.js +130 -0
  29. package/dist/projectDetector.js.map +1 -0
  30. package/dist/proxy/handler.d.ts +5 -0
  31. package/dist/proxy/handler.js +149 -0
  32. package/dist/proxy/handler.js.map +1 -0
  33. package/dist/proxy/headers.d.ts +6 -0
  34. package/dist/proxy/headers.js +52 -0
  35. package/dist/proxy/headers.js.map +1 -0
  36. package/dist/proxy/inspectRequest.d.ts +18 -0
  37. package/dist/proxy/inspectRequest.js +41 -0
  38. package/dist/proxy/inspectRequest.js.map +1 -0
  39. package/dist/server.d.ts +7 -0
  40. package/dist/server.js +66 -0
  41. package/dist/server.js.map +1 -0
  42. package/dist/usage/collector.d.ts +25 -0
  43. package/dist/usage/collector.js +63 -0
  44. package/dist/usage/collector.js.map +1 -0
  45. package/dist/usage/parseAnthropic.d.ts +23 -0
  46. package/dist/usage/parseAnthropic.js +83 -0
  47. package/dist/usage/parseAnthropic.js.map +1 -0
  48. package/dist/usage/parseOpenAI.d.ts +24 -0
  49. package/dist/usage/parseOpenAI.js +72 -0
  50. package/dist/usage/parseOpenAI.js.map +1 -0
  51. package/dist/usage/sse.d.ts +17 -0
  52. package/dist/usage/sse.js +59 -0
  53. package/dist/usage/sse.js.map +1 -0
  54. package/dist/usage/types.d.ts +23 -0
  55. package/dist/usage/types.js +19 -0
  56. package/dist/usage/types.js.map +1 -0
  57. package/package.json +50 -0
@@ -0,0 +1,130 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, sep } from 'node:path';
3
+ import { readRepositoryIdentity } from './gitRepository.js';
4
+ const MAX_CACHE_ENTRIES = 5_000;
5
+ const MAX_STICKY_ENTRIES = 1_000;
6
+ function escapeRegExp(value) {
7
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
+ }
9
+ function normalizeRoot(root) {
10
+ return root.trim().replace(/[\\/]+$/, '');
11
+ }
12
+ /**
13
+ * Deduce il progetto di appartenenza di una richiesta dai path assoluti che
14
+ * questa cita.
15
+ *
16
+ * Le richieste di Copilot Chat trasportano il contesto di lavoro: file aperti,
17
+ * riferimenti espliciti, risultati degli strumenti dell'agente. Tutti nominano
18
+ * file reali sul disco. Risalendo da quei path fino al marker di progetto piu'
19
+ * vicino si ottiene l'attribuzione senza chiedere al developer di configurare
20
+ * nulla per ogni repository, e senza conservare il contenuto della richiesta:
21
+ * dei path si tiene solo l'identita' del repository.
22
+ */
23
+ export class ProjectDetector {
24
+ roots;
25
+ markers;
26
+ stickyTtlMs;
27
+ maxScanBytes;
28
+ pattern;
29
+ /** Path candidato -> repository risolto (o `null` se fuori da ogni progetto). */
30
+ cache = new Map();
31
+ sticky = new Map();
32
+ constructor(options) {
33
+ // I root piu' lunghi vanno provati per primi: con root annidati vince il
34
+ // piu' specifico.
35
+ this.roots = options.roots
36
+ .map(normalizeRoot)
37
+ .filter((root) => root.length > 0)
38
+ .sort((a, b) => b.length - a.length);
39
+ this.markers = options.markers;
40
+ this.stickyTtlMs = options.stickyTtlMs;
41
+ this.maxScanBytes = options.maxScanBytes;
42
+ this.pattern =
43
+ this.roots.length > 0
44
+ ? new RegExp(`(?:${this.roots.map(escapeRegExp).join('|')})(?:/[^\\s"'\`,;:*?<>|()\\[\\]{}\\\\]+)+`, 'g')
45
+ : undefined;
46
+ }
47
+ get enabled() {
48
+ return this.pattern !== undefined;
49
+ }
50
+ /**
51
+ * @param sessionKey chiave della finestra/conversazione, usata per ereditare
52
+ * il progetto nelle richieste che non citano file (titoli, domande secche).
53
+ */
54
+ detect(body, sessionKey, now = Date.now()) {
55
+ const repository = this.fromBody(body);
56
+ if (repository) {
57
+ this.remember(sessionKey, repository, now);
58
+ return { repository, source: 'workspace' };
59
+ }
60
+ const previous = this.sticky.get(sessionKey);
61
+ if (previous && previous.expiresAt > now) {
62
+ return { repository: previous.repository, source: 'session' };
63
+ }
64
+ return undefined;
65
+ }
66
+ fromBody(body) {
67
+ if (!this.pattern || body.length === 0)
68
+ return undefined;
69
+ const text = body.toString('utf-8', 0, Math.min(body.length, this.maxScanBytes));
70
+ const candidates = new Set();
71
+ this.pattern.lastIndex = 0;
72
+ for (let match = this.pattern.exec(text); match; match = this.pattern.exec(text)) {
73
+ candidates.add(match[0]);
74
+ }
75
+ if (candidates.size === 0)
76
+ return undefined;
77
+ // Vince il progetto piu' citato: un riferimento isolato a un file di un
78
+ // altro repository non deve dirottare l'attribuzione.
79
+ const scores = new Map();
80
+ let best;
81
+ let bestScore = 0;
82
+ for (const candidate of candidates) {
83
+ const repository = this.projectFor(candidate);
84
+ if (!repository)
85
+ continue;
86
+ const score = (scores.get(repository.projectId) ?? 0) + 1;
87
+ scores.set(repository.projectId, score);
88
+ if (score > bestScore) {
89
+ best = repository;
90
+ bestScore = score;
91
+ }
92
+ }
93
+ return best;
94
+ }
95
+ /** Risale dal path fino al primo marker di progetto, senza uscire dal root. */
96
+ projectFor(candidate) {
97
+ const cached = this.cache.get(candidate);
98
+ if (cached !== undefined)
99
+ return cached ?? undefined;
100
+ const root = this.roots.find((item) => candidate.startsWith(item) && candidate[item.length] === '/');
101
+ let result;
102
+ let directory = candidate;
103
+ while (root && directory.length > root.length) {
104
+ if (this.markers.some((marker) => existsSync(`${directory}${sep}${marker}`))) {
105
+ result = readRepositoryIdentity(directory);
106
+ break;
107
+ }
108
+ const parent = dirname(directory);
109
+ if (parent === directory)
110
+ break;
111
+ directory = parent;
112
+ }
113
+ if (this.cache.size >= MAX_CACHE_ENTRIES)
114
+ this.cache.clear();
115
+ this.cache.set(candidate, result ?? null);
116
+ return result;
117
+ }
118
+ remember(sessionKey, repository, now) {
119
+ if (this.sticky.size >= MAX_STICKY_ENTRIES) {
120
+ for (const [key, entry] of this.sticky) {
121
+ if (entry.expiresAt <= now)
122
+ this.sticky.delete(key);
123
+ }
124
+ if (this.sticky.size >= MAX_STICKY_ENTRIES)
125
+ this.sticky.clear();
126
+ }
127
+ this.sticky.set(sessionKey, { repository, expiresAt: now + this.stickyTtlMs });
128
+ }
129
+ }
130
+ //# sourceMappingURL=projectDetector.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"projectDetector.js","sourceRoot":"","sources":["../src/projectDetector.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ import type { FastifyReply, FastifyRequest } from 'fastify';
2
+ import type { ClientConfig } from '../config.js';
3
+ import type { UsageForwarder } from '../collector/forwarder.js';
4
+ import { ProjectDetector } from '../projectDetector.js';
5
+ export declare function createProxyHandler(config: ClientConfig, forwarder: UsageForwarder, detector?: ProjectDetector): (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
@@ -0,0 +1,149 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { PassThrough, Readable } from 'node:stream';
3
+ import { Agent, request as undiciRequest } from 'undici';
4
+ import { resolveAttribution } from '../attribution.js';
5
+ import { isStreamingResponse, UsageCollector } from '../usage/collector.js';
6
+ import { buildDownstreamHeaders, buildUpstreamHeaders } from './headers.js';
7
+ import { inspectRequest } from './inspectRequest.js';
8
+ /**
9
+ * Le risposte agentiche possono restare aperte per molti minuti: i timeout di
10
+ * default di undici (300s) interromperebbero gli stream lunghi.
11
+ */
12
+ const dispatcher = new Agent({
13
+ headersTimeout: 0,
14
+ bodyTimeout: 0,
15
+ connect: { timeout: 30_000 },
16
+ });
17
+ /**
18
+ * Header con cui i client identificano la finestra o la conversazione. Servono
19
+ * a tenere separate sessioni parallele dello stesso developer: senza, due
20
+ * progetti aperti insieme condividerebbero l'ereditarieta' del progetto.
21
+ */
22
+ const SESSION_HEADERS = ['vscode-sessionid', 'x-vscode-session-id', 'x-session-id'];
23
+ function sessionKey(headers, bodySessionId, developerId) {
24
+ for (const name of SESSION_HEADERS) {
25
+ const value = headers[name];
26
+ if (typeof value === 'string' && value.length > 0)
27
+ return `s:${value}`;
28
+ }
29
+ return bodySessionId ? `s:${bodySessionId}` : `d:${developerId}`;
30
+ }
31
+ export function createProxyHandler(config, forwarder, detector) {
32
+ const upstream = new URL(config.upstreamBaseUrl);
33
+ return async function proxyHandler(request, reply) {
34
+ const startedAt = Date.now();
35
+ const rawBody = Buffer.isBuffer(request.body) ? request.body : Buffer.alloc(0);
36
+ const attribution = resolveAttribution({ path: request.url, headers: request.headers }, config);
37
+ const [pathname, search = ''] = attribution.upstreamPath.split('?', 2);
38
+ const facts = inspectRequest(pathname, rawBody);
39
+ // Se nessun segnale esplicito ha nominato il progetto, lo si deduce dai
40
+ // path citati nella richiesta: e' cio' che rende superflua la
41
+ // configurazione per singolo repository.
42
+ let { projectId, projectSource } = attribution;
43
+ let repository;
44
+ if (projectSource === 'fallback' && detector?.enabled) {
45
+ const detected = detector.detect(rawBody, sessionKey(request.headers, facts.sessionId, attribution.developerId));
46
+ if (detected) {
47
+ repository = detected.repository;
48
+ projectId = detected.repository.projectId;
49
+ projectSource = detected.source;
50
+ }
51
+ }
52
+ const targetUrl = `${upstream.origin}${upstream.pathname.replace(/\/$/, '')}${pathname}${search ? `?${search}` : ''}`;
53
+ const headers = buildUpstreamHeaders(request.headers, {
54
+ projectHeader: config.projectHeader,
55
+ overrideAuthToken: config.upstreamAuthToken,
56
+ upstreamHost: upstream.host,
57
+ });
58
+ if (facts.body.length > 0)
59
+ headers.set('content-length', String(facts.body.length));
60
+ const controller = new AbortController();
61
+ // L'upstream va interrotto solo se e' il client a sganciarsi prima della
62
+ // fine: `request.raw` chiude gia' a corpo letto, quindi non e' un segnale
63
+ // valido di disconnessione.
64
+ reply.raw.on('close', () => {
65
+ if (!reply.raw.writableEnded)
66
+ controller.abort();
67
+ });
68
+ let upstreamResponse;
69
+ try {
70
+ upstreamResponse = await undiciRequest(targetUrl, {
71
+ method: request.method,
72
+ headers: Object.fromEntries(headers.entries()),
73
+ body: facts.body.length > 0 ? facts.body : undefined,
74
+ dispatcher,
75
+ signal: controller.signal,
76
+ });
77
+ }
78
+ catch (error) {
79
+ request.log.error({ err: error, targetUrl }, 'richiesta upstream fallita');
80
+ await reply.code(502).send({ error: 'upstream_unreachable' });
81
+ return;
82
+ }
83
+ const responseHeaders = new Headers();
84
+ for (const [name, value] of Object.entries(upstreamResponse.headers)) {
85
+ if (value === undefined)
86
+ continue;
87
+ for (const item of Array.isArray(value) ? value : [value])
88
+ responseHeaders.append(name, item);
89
+ }
90
+ const contentType = responseHeaders.get('content-type') ?? undefined;
91
+ const streaming = isStreamingResponse(contentType);
92
+ const collector = new UsageCollector(streaming);
93
+ const requestId = responseHeaders.get('x-request-id') ??
94
+ request.headers['x-request-id'] ??
95
+ randomUUID();
96
+ let recorded = false;
97
+ const record = () => {
98
+ if (recorded)
99
+ return;
100
+ recorded = true;
101
+ const parsed = collector.finish();
102
+ // Nessun contatore: probabilmente non e' una richiesta di inferenza
103
+ // (es. /models, health check). Non va registrata.
104
+ if (!parsed)
105
+ return;
106
+ forwarder.enqueue({
107
+ requestId,
108
+ occurredAt: new Date(startedAt).toISOString(),
109
+ developerId: attribution.developerId,
110
+ developerSource: attribution.developerSource,
111
+ projectId,
112
+ projectSource,
113
+ repository: repository ?? null,
114
+ clientName: attribution.clientName,
115
+ sessionId: facts.sessionId ?? null,
116
+ endpoint: pathname,
117
+ model: parsed.model ?? facts.model ?? null,
118
+ usage: {
119
+ inputTokens: parsed.inputTokens,
120
+ cachedInputTokens: parsed.cachedInputTokens,
121
+ cacheWriteTokens: parsed.cacheWriteTokens,
122
+ outputTokens: parsed.outputTokens,
123
+ },
124
+ streamed: streaming,
125
+ durationMs: Date.now() - startedAt,
126
+ });
127
+ };
128
+ reply.code(upstreamResponse.statusCode);
129
+ reply.headers(buildDownstreamHeaders(responseHeaders));
130
+ const source = Readable.from(upstreamResponse.body, { objectMode: false });
131
+ const passthrough = new PassThrough();
132
+ source.on('data', (chunk) => {
133
+ try {
134
+ collector.push(chunk);
135
+ }
136
+ catch (error) {
137
+ request.log.warn({ err: error }, 'parsing usage fallito');
138
+ }
139
+ });
140
+ // Anche in caso di interruzione i token di input sono stati consumati:
141
+ // si registra comunque quanto raccolto.
142
+ source.on('end', record);
143
+ source.on('error', record);
144
+ source.on('close', record);
145
+ source.pipe(passthrough);
146
+ await reply.send(passthrough);
147
+ };
148
+ }
149
+ //# sourceMappingURL=handler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.js","sourceRoot":"","sources":["../../src/proxy/handler.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ export declare function buildUpstreamHeaders(incoming: Record<string, string | string[] | undefined>, options: {
2
+ projectHeader: string;
3
+ overrideAuthToken?: string | undefined;
4
+ upstreamHost: string;
5
+ }): Headers;
6
+ export declare function buildDownstreamHeaders(upstream: Headers): Record<string, string>;
@@ -0,0 +1,52 @@
1
+ /** Header hop-by-hop: non vanno mai propagati oltre il proxy. */
2
+ const HOP_BY_HOP = new Set([
3
+ 'connection',
4
+ 'keep-alive',
5
+ 'proxy-authenticate',
6
+ 'proxy-authorization',
7
+ 'te',
8
+ 'trailer',
9
+ 'transfer-encoding',
10
+ 'upgrade',
11
+ ]);
12
+ /** Header interni al proxy, non devono raggiungere l'upstream. */
13
+ const INTERNAL_HEADERS = new Set(['x-project-id', 'x-developer-id']);
14
+ export function buildUpstreamHeaders(incoming, options) {
15
+ const headers = new Headers();
16
+ for (const [name, value] of Object.entries(incoming)) {
17
+ const key = name.toLowerCase();
18
+ if (HOP_BY_HOP.has(key) || INTERNAL_HEADERS.has(key))
19
+ continue;
20
+ if (key === options.projectHeader)
21
+ continue;
22
+ if (key === 'host' || key === 'content-length')
23
+ continue;
24
+ if (value === undefined)
25
+ continue;
26
+ for (const item of Array.isArray(value) ? value : [value])
27
+ headers.append(name, item);
28
+ }
29
+ headers.set('host', options.upstreamHost);
30
+ // Il corpo deve arrivare in chiaro: undici non decomprime automaticamente e
31
+ // uno stream SSE compresso sarebbe illeggibile sia per il parser di usage sia
32
+ // per il client, a cui inoltriamo i byte cosi' come li riceviamo.
33
+ headers.set('accept-encoding', 'identity');
34
+ if (options.overrideAuthToken) {
35
+ headers.set('authorization', `Bearer ${options.overrideAuthToken}`);
36
+ }
37
+ return headers;
38
+ }
39
+ export function buildDownstreamHeaders(upstream) {
40
+ const out = {};
41
+ upstream.forEach((value, name) => {
42
+ const key = name.toLowerCase();
43
+ if (HOP_BY_HOP.has(key))
44
+ return;
45
+ // La lunghezza viene ricalcolata da Node: il corpo passa in streaming.
46
+ if (key === 'content-length' || key === 'content-encoding')
47
+ return;
48
+ out[name] = value;
49
+ });
50
+ return out;
51
+ }
52
+ //# sourceMappingURL=headers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"headers.js","sourceRoot":"","sources":["../../src/proxy/headers.ts"],"names":[],"mappings":"AAAA,iEAAiE;AACjE,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC;IACzB,YAAY;IACZ,YAAY;IACZ,oBAAoB;IACpB,qBAAqB;IACrB,IAAI;IACJ,SAAS;IACT,mBAAmB;IACnB,SAAS;CACV,CAAC,CAAC;AAEH,kEAAkE;AAClE,MAAM,gBAAgB,GAAG,IAAI,GAAG,CAAC,CAAC,cAAc,EAAE,gBAAgB,CAAC,CAAC,CAAC;AAErE,MAAM,UAAU,oBAAoB,CAClC,QAAuD,EACvD,OAAgG;IAEhG,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;IAE9B,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;QACrD,MAAM,GAAG,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QAC/B,IAAI,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,gBAAgB,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,SAAS;QAC/D,IAAI,GAAG,KAAK,OAAO,CAAC,aAAa;YAAE,SAAS;QAC5C,IAAI,GAAG,KAAK,MAAM,IAAI,GAAG,KAAK,gBAAgB;YAAE,SAAS;QACzD,IAAI,KAAK,KAAK,SAAS;YAAE,SAAS;QAElC,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;YAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACxF,CAAC;IAED,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC;IAE1C,4EAA4E;IAC5E,8EAA8E;IAC9E,kEAAkE;IAClE,OAAO,CAAC,GAAG,CAAC,iBAAiB,EAAE,UAAU,CAAC,CAAC;IAE3C,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;QAC9B,OAAO,CAAC,GAAG,CAAC,eAAe,EAAE,UAAU,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;IACtE,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,QAAiB;IACtD,MAAM,GAAG,GAA2B,EAAE,CAAC;IACvC,QAAQ,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE;QAC/B,MAAM,GAAG,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QAC/B,IAAI,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,OAAO;QAChC,uEAAuE;QACvE,IAAI,GAAG,KAAK,gBAAgB,IAAI,GAAG,KAAK,kBAAkB;YAAE,OAAO;QACnE,GAAG,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC;IACpB,CAAC,CAAC,CAAC;IACH,OAAO,GAAG,CAAC;AACb,CAAC"}
@@ -0,0 +1,18 @@
1
+ interface RequestFacts {
2
+ model?: string;
3
+ sessionId?: string;
4
+ stream: boolean;
5
+ /** Corpo eventualmente riscritto per abilitare la contabilizzazione. */
6
+ body: Buffer;
7
+ }
8
+ /**
9
+ * Ispeziona il corpo della richiesta per ricavare modello e sessione, e
10
+ * garantisce che l'upstream restituisca i contatori di usage.
11
+ *
12
+ * Per gli endpoint in stile OpenAI l'usage in streaming e' opzionale e va
13
+ * richiesto esplicitamente con `stream_options.include_usage`; senza di esso
14
+ * la risposta non conterrebbe alcun contatore. Gli endpoint Anthropic lo
15
+ * includono sempre e il corpo resta invariato.
16
+ */
17
+ export declare function inspectRequest(path: string, body: Buffer): RequestFacts;
18
+ export {};
@@ -0,0 +1,41 @@
1
+ const SESSION_PATTERN = /session_([0-9a-f-]{8,})/i;
2
+ function asObject(value) {
3
+ return typeof value === 'object' && value !== null ? value : undefined;
4
+ }
5
+ /**
6
+ * Ispeziona il corpo della richiesta per ricavare modello e sessione, e
7
+ * garantisce che l'upstream restituisca i contatori di usage.
8
+ *
9
+ * Per gli endpoint in stile OpenAI l'usage in streaming e' opzionale e va
10
+ * richiesto esplicitamente con `stream_options.include_usage`; senza di esso
11
+ * la risposta non conterrebbe alcun contatore. Gli endpoint Anthropic lo
12
+ * includono sempre e il corpo resta invariato.
13
+ */
14
+ export function inspectRequest(path, body) {
15
+ if (body.length === 0)
16
+ return { stream: false, body };
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(body.toString('utf-8'));
20
+ }
21
+ catch {
22
+ return { stream: false, body };
23
+ }
24
+ const root = asObject(parsed);
25
+ if (!root)
26
+ return { stream: false, body };
27
+ const model = typeof root.model === 'string' ? root.model : undefined;
28
+ const stream = root.stream === true;
29
+ let sessionId;
30
+ const metadata = asObject(root.metadata);
31
+ if (typeof metadata?.user_id === 'string') {
32
+ sessionId = SESSION_PATTERN.exec(metadata.user_id)?.[1];
33
+ }
34
+ const isOpenAiStyle = path.includes('/chat/completions') || path.includes('/responses');
35
+ if (stream && isOpenAiStyle && root.stream_options === undefined) {
36
+ root.stream_options = { include_usage: true };
37
+ return { model, sessionId, stream, body: Buffer.from(JSON.stringify(root), 'utf-8') };
38
+ }
39
+ return { model, sessionId, stream, body };
40
+ }
41
+ //# sourceMappingURL=inspectRequest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inspectRequest.js","sourceRoot":"","sources":["../../src/proxy/inspectRequest.ts"],"names":[],"mappings":"AAQA,MAAM,eAAe,GAAG,0BAA0B,CAAC;AAEnD,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,CAAC,CAAC,CAAE,KAAiC,CAAC,CAAC,CAAC,SAAS,CAAC;AACtG,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,cAAc,CAAC,IAAY,EAAE,IAAY;IACvD,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IAEtD,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;IAC9C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IACjC,CAAC;IAED,MAAM,IAAI,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC9B,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IAE1C,MAAM,KAAK,GAAG,OAAO,IAAI,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;IACtE,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC;IAEpC,IAAI,SAA6B,CAAC;IAClC,MAAM,QAAQ,GAAG,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACzC,IAAI,OAAO,QAAQ,EAAE,OAAO,KAAK,QAAQ,EAAE,CAAC;QAC1C,SAAS,GAAG,eAAe,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAC1D,CAAC;IAED,MAAM,aAAa,GAAG,IAAI,CAAC,QAAQ,CAAC,mBAAmB,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;IACxF,IAAI,MAAM,IAAI,aAAa,IAAI,IAAI,CAAC,cAAc,KAAK,SAAS,EAAE,CAAC;QACjE,IAAI,CAAC,cAAc,GAAG,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC;QAC9C,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC,EAAE,CAAC;IACxF,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;AAC5C,CAAC"}
@@ -0,0 +1,7 @@
1
+ import { type FastifyInstance } from 'fastify';
2
+ import type { ClientConfig } from './config.js';
3
+ export interface App {
4
+ server: FastifyInstance;
5
+ close: () => Promise<void>;
6
+ }
7
+ export declare function buildApp(config: ClientConfig): Promise<App>;
package/dist/server.js ADDED
@@ -0,0 +1,66 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import Fastify from 'fastify';
3
+ import { UsageForwarder } from './collector/forwarder.js';
4
+ import { createProxyHandler } from './proxy/handler.js';
5
+ import { ProjectDetector } from './projectDetector.js';
6
+ function createServer(config) {
7
+ const options = {
8
+ logger: { level: config.logLevel },
9
+ // Le sessioni agentiche possono restare aperte a lungo.
10
+ requestTimeout: 0,
11
+ keepAliveTimeout: 120_000,
12
+ bodyLimit: 64 * 1024 * 1024,
13
+ // Il log per-richiesta duplicherebbe il record di usage senza aggiungere
14
+ // nulla. Deprecato in Fastify 5, da migrare a `logController` in v6.
15
+ disableRequestLogging: true,
16
+ };
17
+ if (!config.tlsKeyPath || !config.tlsCertPath)
18
+ return Fastify(options);
19
+ // In TLS l'istanza Fastify e' tipizzata su https.Server: l'interfaccia
20
+ // pubblica usata qui e' identica, quindi si normalizza il tipo.
21
+ return Fastify({
22
+ ...options,
23
+ https: {
24
+ key: readFileSync(config.tlsKeyPath),
25
+ cert: readFileSync(config.tlsCertPath),
26
+ },
27
+ });
28
+ }
29
+ export async function buildApp(config) {
30
+ const server = createServer(config);
31
+ // Il proxy deve inoltrare il corpo byte per byte, senza reinterpretarlo.
32
+ server.removeAllContentTypeParsers();
33
+ server.addContentTypeParser('*', { parseAs: 'buffer' }, (_request, body, done) => {
34
+ done(null, body);
35
+ });
36
+ const forwarder = new UsageForwarder(config, server.log);
37
+ forwarder.start();
38
+ const detector = new ProjectDetector({
39
+ roots: config.workspaceRoots,
40
+ markers: config.projectMarkers,
41
+ stickyTtlMs: config.projectStickyTtlMs,
42
+ maxScanBytes: 4 * 1024 * 1024,
43
+ });
44
+ if (!detector.enabled) {
45
+ server.log.warn('WORKSPACE_ROOTS non impostato: il progetto va dichiarato esplicitamente, altrimenti il traffico finisce in `unassigned`');
46
+ }
47
+ server.get('/_health', async () => ({
48
+ ok: true,
49
+ upstream: config.upstreamBaseUrl,
50
+ collector: config.collectorUrl,
51
+ projectDetection: detector.enabled,
52
+ }));
53
+ server.route({
54
+ method: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'],
55
+ url: '/*',
56
+ handler: createProxyHandler(config, forwarder, detector),
57
+ });
58
+ return {
59
+ server,
60
+ close: async () => {
61
+ await server.close();
62
+ await forwarder.close();
63
+ },
64
+ };
65
+ }
66
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,25 @@
1
+ import type { ParsedUsage } from './types.js';
2
+ /**
3
+ * Raccoglitore di usage indipendente dal dialetto upstream.
4
+ *
5
+ * Il proxy non sa a priori se la risposta e' in formato Anthropic Messages o
6
+ * OpenAI chat completions (dipende da come e' configurato l'upstream), quindi
7
+ * alimenta entrambi gli accumulatori e tiene il primo che produce un risultato.
8
+ * Gli eventi dell'uno sono inerti per l'altro, perche' le forme dei payload
9
+ * non si sovrappongono.
10
+ */
11
+ export declare class UsageCollector {
12
+ private readonly streaming;
13
+ private readonly anthropic;
14
+ private readonly openai;
15
+ private readonly sse;
16
+ private nonStreamChunks;
17
+ private nonStreamBytes;
18
+ /** Oltre questa soglia si smette di bufferizzare una risposta non-stream. */
19
+ private static readonly MAX_BUFFERED_BYTES;
20
+ constructor(streaming: boolean);
21
+ push(chunk: Uint8Array): void;
22
+ finish(): ParsedUsage | undefined;
23
+ }
24
+ /** Euristica: la risposta e' uno stream SSE? */
25
+ export declare function isStreamingResponse(contentType: string | undefined): boolean;
@@ -0,0 +1,63 @@
1
+ import { AnthropicUsageAccumulator, parseAnthropicResponse } from './parseAnthropic.js';
2
+ import { OpenAIUsageAccumulator, parseOpenAIResponse } from './parseOpenAI.js';
3
+ import { SseParser } from './sse.js';
4
+ /**
5
+ * Raccoglitore di usage indipendente dal dialetto upstream.
6
+ *
7
+ * Il proxy non sa a priori se la risposta e' in formato Anthropic Messages o
8
+ * OpenAI chat completions (dipende da come e' configurato l'upstream), quindi
9
+ * alimenta entrambi gli accumulatori e tiene il primo che produce un risultato.
10
+ * Gli eventi dell'uno sono inerti per l'altro, perche' le forme dei payload
11
+ * non si sovrappongono.
12
+ */
13
+ export class UsageCollector {
14
+ streaming;
15
+ anthropic = new AnthropicUsageAccumulator();
16
+ openai = new OpenAIUsageAccumulator();
17
+ sse;
18
+ nonStreamChunks = [];
19
+ nonStreamBytes = 0;
20
+ /** Oltre questa soglia si smette di bufferizzare una risposta non-stream. */
21
+ static MAX_BUFFERED_BYTES = 8 * 1024 * 1024;
22
+ constructor(streaming) {
23
+ this.streaming = streaming;
24
+ this.sse = new SseParser((payload) => {
25
+ this.anthropic.handleEvent(payload);
26
+ this.openai.handleEvent(payload);
27
+ });
28
+ }
29
+ push(chunk) {
30
+ if (this.streaming) {
31
+ this.sse.push(chunk);
32
+ return;
33
+ }
34
+ if (this.nonStreamBytes + chunk.byteLength > UsageCollector.MAX_BUFFERED_BYTES)
35
+ return;
36
+ this.nonStreamChunks.push(chunk);
37
+ this.nonStreamBytes += chunk.byteLength;
38
+ }
39
+ finish() {
40
+ if (this.streaming) {
41
+ this.sse.end();
42
+ return this.anthropic.result() ?? this.openai.result();
43
+ }
44
+ if (this.nonStreamChunks.length === 0)
45
+ return undefined;
46
+ let body;
47
+ try {
48
+ body = JSON.parse(Buffer.concat(this.nonStreamChunks).toString('utf-8'));
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ finally {
54
+ this.nonStreamChunks = [];
55
+ }
56
+ return parseAnthropicResponse(body) ?? parseOpenAIResponse(body);
57
+ }
58
+ }
59
+ /** Euristica: la risposta e' uno stream SSE? */
60
+ export function isStreamingResponse(contentType) {
61
+ return (contentType ?? '').toLowerCase().includes('text/event-stream');
62
+ }
63
+ //# sourceMappingURL=collector.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"collector.js","sourceRoot":"","sources":["../../src/usage/collector.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AACxF,OAAO,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAC/E,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAGrC;;;;;;;;GAQG;AACH,MAAM,OAAO,cAAc;IAUI;IATZ,SAAS,GAAG,IAAI,yBAAyB,EAAE,CAAC;IAC5C,MAAM,GAAG,IAAI,sBAAsB,EAAE,CAAC;IACtC,GAAG,CAAY;IACxB,eAAe,GAAiB,EAAE,CAAC;IACnC,cAAc,GAAG,CAAC,CAAC;IAE3B,6EAA6E;IACrE,MAAM,CAAU,kBAAkB,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;IAE7D,YAA6B,SAAkB;QAAlB,cAAS,GAAT,SAAS,CAAS;QAC7C,IAAI,CAAC,GAAG,GAAG,IAAI,SAAS,CAAC,CAAC,OAAO,EAAE,EAAE;YACnC,IAAI,CAAC,SAAS,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;YACpC,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;QACnC,CAAC,CAAC,CAAC;IACL,CAAC;IAED,IAAI,CAAC,KAAiB;QACpB,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YACnB,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACrB,OAAO;QACT,CAAC;QACD,IAAI,IAAI,CAAC,cAAc,GAAG,KAAK,CAAC,UAAU,GAAG,cAAc,CAAC,kBAAkB;YAAE,OAAO;QACvF,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACjC,IAAI,CAAC,cAAc,IAAI,KAAK,CAAC,UAAU,CAAC;IAC1C,CAAC;IAED,MAAM;QACJ,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YACnB,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC;YACf,OAAO,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC;QACzD,CAAC;QAED,IAAI,IAAI,CAAC,eAAe,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,SAAS,CAAC;QACxD,IAAI,IAAa,CAAC;QAClB,IAAI,CAAC;YACH,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;QAC3E,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,SAAS,CAAC;QACnB,CAAC;gBAAS,CAAC;YACT,IAAI,CAAC,eAAe,GAAG,EAAE,CAAC;QAC5B,CAAC;QACD,OAAO,sBAAsB,CAAC,IAAI,CAAC,IAAI,mBAAmB,CAAC,IAAI,CAAC,CAAC;IACnE,CAAC;;AAGH,gDAAgD;AAChD,MAAM,UAAU,mBAAmB,CAAC,WAA+B;IACjE,OAAO,CAAC,WAAW,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,mBAAmB,CAAC,CAAC;AACzE,CAAC"}
@@ -0,0 +1,23 @@
1
+ import { type ParsedUsage } from './types.js';
2
+ /**
3
+ * Estrae i token da un blocco `usage` in formato Anthropic Messages API.
4
+ *
5
+ * In Anthropic `input_tokens` esclude gia' i token di cache, quindi non
6
+ * serve alcuna sottrazione.
7
+ */
8
+ export declare function readAnthropicUsageBlock(usage: unknown): ParsedUsage | undefined;
9
+ /**
10
+ * Accumulatore per uno stream Anthropic.
11
+ *
12
+ * Lo stream distribuisce l'informazione su due eventi:
13
+ * - `message_start` porta gli input token (definitivi) e un output parziale
14
+ * - `message_delta` porta gli output token cumulativi finali
15
+ */
16
+ export declare class AnthropicUsageAccumulator {
17
+ private usage;
18
+ private seen;
19
+ handleEvent(payload: unknown): void;
20
+ result(): ParsedUsage | undefined;
21
+ }
22
+ /** Risposta Anthropic non-streaming. */
23
+ export declare function parseAnthropicResponse(body: unknown): ParsedUsage | undefined;