Skip to content

Commit 8c46503

Browse files
committed
2 parents 1d49f75 + 41cf5ac commit 8c46503

5 files changed

Lines changed: 455 additions & 3 deletions

File tree

containers/api-proxy/Dockerfile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ RUN npm ci --omit=dev
1717
# Copy application files
1818
COPY server.js logging.js metrics.js rate-limiter.js \
1919
token-tracker.js token-persistence.js token-parsers.js \
20-
token-tracker-http.js token-tracker-ws.js \
20+
token-tracker-http.js token-tracker-ws.js token-tracker-shared.js \
2121
model-resolver.js model-utils.js model-body-rewriter.js proxy-utils.js adapter-factory.js anthropic-transforms.js \
2222
model-config.js key-validation.js server-factory.js startup.js \
2323
proxy-request.js http-client.js body-handler.js model-discovery.js management.js oidc-token-provider.js \
@@ -27,7 +27,8 @@ COPY server.js logging.js metrics.js rate-limiter.js \
2727
ai-credits-pricing.js models-dev-catalog.js models.dev.catalog.json \
2828
oidc-refresh-utils.js body-transform.js body-utils.js rate-limit.js websocket-proxy.js \
2929
deprecated-header-tracker.js billing-headers.js upstream-response.js \
30-
anthropic-cache.js otel.js token-budget-log.js blocked-request-diagnostics.js \
30+
anthropic-cache.js otel.js otel-exporters.js otel-serialization.js \
31+
token-budget-log.js blocked-request-diagnostics.js \
3132
provider-env-constants.js ./
3233
COPY guards/ ./guards/
3334
COPY providers/ ./providers/
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Guard test: every local module reachable from the runtime entrypoint
3+
* (server.js) MUST be present in the Dockerfile COPY list.
4+
*
5+
* Background: the api-proxy image copies source files individually by name
6+
* (no bundler). When a refactor adds a new module but forgets to update the
7+
* Dockerfile, `require()` throws MODULE_NOT_FOUND inside the container. The
8+
* proxy's graceful-degradation guards then silently stub the affected
9+
* subsystem (e.g. token tracking, OTEL), so the container still boots but
10+
* produces no token-usage.jsonl — causing AI-credit accounting to report 0.
11+
*
12+
* This regression has happened at least twice (OIDC modules, then
13+
* token-tracker-shared.js). This test fails fast in CI instead.
14+
*/
15+
16+
const fs = require('fs');
17+
const path = require('path');
18+
19+
const ROOT = __dirname;
20+
const ENTRYPOINT = path.join(ROOT, 'server.js');
21+
22+
/** Resolve a relative require spec to an existing file path, or null. */
23+
function resolveLocal(fromFile, spec) {
24+
const base = path.resolve(path.dirname(fromFile), spec);
25+
const candidates = [base, `${base}.js`, `${base}.json`, path.join(base, 'index.js')];
26+
for (const c of candidates) {
27+
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
28+
}
29+
return null;
30+
}
31+
32+
/** Compute the transitive closure of local (./ and ../) requires from an entry file. */
33+
function computeRequireClosure(entry) {
34+
const seen = new Set();
35+
const stack = [entry];
36+
const requireRe = /require\(\s*(["'])(\.{1,2}\/[^"']+)\1\s*\)/g;
37+
38+
while (stack.length > 0) {
39+
const file = stack.pop();
40+
if (seen.has(file)) continue;
41+
seen.add(file);
42+
43+
let src;
44+
try {
45+
src = fs.readFileSync(file, 'utf8');
46+
} catch {
47+
continue;
48+
}
49+
50+
let m;
51+
while ((m = requireRe.exec(src)) !== null) {
52+
const resolved = resolveLocal(file, m[2]);
53+
if (resolved && !resolved.includes(`${path.sep}node_modules${path.sep}`)) {
54+
stack.push(resolved);
55+
}
56+
}
57+
}
58+
return seen;
59+
}
60+
61+
/** Parse the Dockerfile into a set of copied files and copied directory prefixes. */
62+
function parseDockerfileCopies(dockerfilePath) {
63+
const lines = fs.readFileSync(dockerfilePath, 'utf8').split('\n');
64+
const files = new Set();
65+
const dirs = new Set();
66+
67+
let inCopy = false;
68+
for (const rawLine of lines) {
69+
const line = rawLine.trim();
70+
if (line.startsWith('#')) continue;
71+
72+
let body = line;
73+
if (line.startsWith('COPY ')) {
74+
inCopy = true;
75+
body = line.slice('COPY '.length);
76+
} else if (!inCopy) {
77+
continue;
78+
}
79+
80+
const continues = body.endsWith('\\');
81+
body = body.replace(/\\$/, '').trim();
82+
83+
for (const tok of body.split(/\s+/)) {
84+
if (!tok || tok === '.' || tok === './') continue;
85+
const clean = tok.replace(/^\.\//, '');
86+
if (clean.endsWith('/')) {
87+
dirs.add(clean);
88+
} else if (/\.(js|json)$/.test(clean)) {
89+
files.add(clean);
90+
}
91+
}
92+
93+
if (!continues) inCopy = false;
94+
}
95+
return { files, dirs };
96+
}
97+
98+
describe('Dockerfile COPY coverage', () => {
99+
test('every module reachable from server.js is copied into the image', () => {
100+
const closure = computeRequireClosure(ENTRYPOINT);
101+
const { files, dirs } = parseDockerfileCopies(path.join(ROOT, 'Dockerfile'));
102+
103+
const isCopied = (relPath) => {
104+
if (files.has(relPath)) return true;
105+
for (const dir of dirs) {
106+
if (relPath.startsWith(dir)) return true;
107+
}
108+
return false;
109+
};
110+
111+
const missing = [...closure]
112+
.map((abs) => path.relative(ROOT, abs).split(path.sep).join('/'))
113+
.filter((rel) => !rel.startsWith('node_modules'))
114+
.filter((rel) => !isCopied(rel))
115+
.sort();
116+
117+
expect(missing).toEqual([]);
118+
});
119+
});

containers/api-proxy/token-parsers.js

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,71 @@ function extractCacheReadTokens(usage) {
118118
return undefined;
119119
}
120120

121+
/**
122+
* Extract the authoritative per-type token breakdown from a Copilot
123+
* `copilot_usage.token_details` array.
124+
*
125+
* The GitHub Copilot OpenAI-compatible endpoint reports a flattened
126+
* `usage` object where `prompt_tokens` lumps fresh input together with
127+
* cache-write tokens, and `prompt_tokens_details.cached_tokens` only
128+
* carries cache-read. The true split (input / cache_read / cache_write /
129+
* output), which is billed at distinct rates, is only available in the
130+
* sibling `copilot_usage.token_details` array, e.g.:
131+
*
132+
* copilot_usage: { token_details: [
133+
* { token_type: "input", token_count: 3857 },
134+
* { token_type: "cache_read", token_count: 0 },
135+
* { token_type: "cache_write", token_count: 12539 },
136+
* { token_type: "output", token_count: 362 },
137+
* ] }
138+
*
139+
* Returns Anthropic-normalized usage fields (input_tokens, output_tokens,
140+
* cache_read_input_tokens, cache_creation_input_tokens) so downstream
141+
* normalization records the correct cache_write split, or null when no
142+
* recognizable token_details are present.
143+
*
144+
* @param {object} json - Parsed response JSON (or SSE event object)
145+
* @returns {object|null}
146+
*/
147+
function extractCopilotUsageBreakdown(json) {
148+
if (!json || typeof json !== 'object') return null;
149+
const copilotUsage = (json.copilot_usage && typeof json.copilot_usage === 'object')
150+
? json.copilot_usage
151+
: ((json.response && json.response.copilot_usage && typeof json.response.copilot_usage === 'object')
152+
? json.response.copilot_usage
153+
: null);
154+
if (!copilotUsage || !Array.isArray(copilotUsage.token_details)) return null;
155+
156+
const out = {};
157+
let found = false;
158+
for (const entry of copilotUsage.token_details) {
159+
if (!entry || typeof entry !== 'object') continue;
160+
const count = entry.token_count;
161+
if (typeof count !== 'number') continue;
162+
switch (entry.token_type) {
163+
case 'input':
164+
out.input_tokens = (out.input_tokens || 0) + count;
165+
found = true;
166+
break;
167+
case 'output':
168+
out.output_tokens = (out.output_tokens || 0) + count;
169+
found = true;
170+
break;
171+
case 'cache_read':
172+
out.cache_read_input_tokens = (out.cache_read_input_tokens || 0) + count;
173+
found = true;
174+
break;
175+
case 'cache_write':
176+
out.cache_creation_input_tokens = (out.cache_creation_input_tokens || 0) + count;
177+
found = true;
178+
break;
179+
default:
180+
break;
181+
}
182+
}
183+
return found ? out : null;
184+
}
185+
121186
/**
122187
* Extract token usage from a non-streaming JSON response body.
123188
*
@@ -185,6 +250,26 @@ function extractUsageFromJson(body) {
185250
}
186251
}
187252

253+
// Copilot exposes the authoritative input/cache_read/cache_write/output
254+
// split only in the sibling `copilot_usage.token_details` array. When
255+
// present, prefer it: the flattened `usage.prompt_tokens` lumps fresh
256+
// input together with cache-write tokens (billed at different rates).
257+
const copilotBreakdown = extractCopilotUsageBreakdown(json);
258+
if (copilotBreakdown) {
259+
const merged = { ...(result.usage || {}), ...copilotBreakdown };
260+
if (copilotBreakdown.input_tokens !== undefined) {
261+
// Copilot gave us a precise input split: drop the lumped prompt_tokens.
262+
delete merged.prompt_tokens;
263+
} else if (copilotBreakdown.cache_creation_input_tokens !== undefined
264+
&& typeof merged.prompt_tokens === 'number') {
265+
// cache_write present but input absent: infer input = prompt_tokens - cache_write
266+
// to avoid double-counting cache_write in normalizeUsage.
267+
merged.input_tokens = Math.max(0, merged.prompt_tokens - copilotBreakdown.cache_creation_input_tokens);
268+
delete merged.prompt_tokens;
269+
}
270+
result.usage = merged;
271+
}
272+
188273
return result;
189274
} catch {
190275
return { usage: null, model: null };
@@ -260,6 +345,20 @@ function extractUsageFromSseLine(line) {
260345
}
261346
const cacheReadTokens = extractCacheReadTokens(json.usage);
262347
if (typeof cacheReadTokens === 'number') result.usage.cache_read_input_tokens = cacheReadTokens;
348+
const copilotBreakdown = extractCopilotUsageBreakdown(json);
349+
if (copilotBreakdown) {
350+
result.usage = { ...result.usage, ...copilotBreakdown };
351+
if (copilotBreakdown.input_tokens !== undefined) {
352+
// Copilot gave us a precise input split: drop the lumped prompt_tokens.
353+
delete result.usage.prompt_tokens;
354+
} else if (copilotBreakdown.cache_creation_input_tokens !== undefined
355+
&& typeof result.usage.prompt_tokens === 'number') {
356+
// cache_write present but input absent: infer input = prompt_tokens - cache_write
357+
// to avoid double-counting cache_write in normalizeUsage.
358+
result.usage.input_tokens = Math.max(0, result.usage.prompt_tokens - copilotBreakdown.cache_creation_input_tokens);
359+
delete result.usage.prompt_tokens;
360+
}
361+
}
263362
return result;
264363
}
265364

@@ -294,7 +393,8 @@ function parseSseDataLines(text) {
294393
* - input_tokens: number (from Anthropic input_tokens or OpenAI prompt_tokens)
295394
* - output_tokens: number (from Anthropic output_tokens or OpenAI completion_tokens)
296395
* - cache_read_tokens: number (from Anthropic cache_read_input_tokens or OpenAI prompt_tokens_details.cached_tokens)
297-
* - cache_write_tokens: number (Anthropic cache_creation_input_tokens; not available in OpenAI format)
396+
* - cache_write_tokens: number (Anthropic cache_creation_input_tokens or
397+
* Copilot copilot_usage cache_write; not available in flattened OpenAI usage)
298398
*/
299399
function normalizeUsage(usage) {
300400
if (!usage) return null;
@@ -314,6 +414,7 @@ module.exports = {
314414
createDecompressor,
315415
extractReasoningTokens,
316416
extractCacheReadTokens,
417+
extractCopilotUsageBreakdown,
317418
extractUsageFromJson,
318419
extractUsageFromSseLine,
319420
parseSseDataLines,

containers/api-proxy/token-tracker.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const {
2525
normalizeUsage,
2626
isStreamingResponse,
2727
isCompressedResponse,
28+
extractCopilotUsageBreakdown,
2829
} = require('./token-parsers');
2930

3031
module.exports = {
@@ -39,6 +40,7 @@ module.exports = {
3940
normalizeUsage,
4041
isStreamingResponse,
4142
isCompressedResponse,
43+
extractCopilotUsageBreakdown,
4244
validateTokenUsageRecord,
4345
writeTokenUsage,
4446
TOKEN_LOG_FILE,

0 commit comments

Comments
 (0)