Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion containers/api-proxy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ COPY server.js logging.js metrics.js rate-limiter.js \
github-oidc.js aws-oidc-token-provider.js gcp-oidc-token-provider.js \
anthropic-oidc-token-provider.js \
oidc-refresh-utils.js body-transform.js rate-limit.js websocket-proxy.js \
otel.js ./
deprecated-header-tracker.js billing-headers.js upstream-response.js \
anthropic-cache.js otel.js ./
COPY guards/ ./guards/
COPY providers/ ./providers/
COPY transforms/ ./transforms/
Expand Down
47 changes: 47 additions & 0 deletions containers/api-proxy/billing-headers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use strict';

/**
* Extract billing/quota information from upstream response headers.
*
* CAPI returns quota snapshots as `X-Quota-Snapshot-<Type>` headers with
* URL-encoded fields: ent (entitlement), ov (overage), ovPerm (overage allowed),
* rem (remaining %), rst (reset date).
*
* Also captures X-RateLimit-* headers from CAPI responses.
*
* @param {Record<string, string|string[]>} headers - Response headers
* @returns {object|null} Billing info object, or null if no billing headers present
*/
function extractBillingHeaders(headers) {
const billing = {};
let hasBilling = false;

for (const [name, value] of Object.entries(headers)) {
const lower = name.toLowerCase();
if (lower.startsWith('x-quota-snapshot-')) {
const quotaType = lower.slice('x-quota-snapshot-'.length);
try {
const params = new URLSearchParams(String(value));
const snapshot = {};
for (const [k, v] of params) snapshot[k] = v;
billing[`quota_${quotaType}`] = snapshot;
} catch {
billing[`quota_${quotaType}_raw`] = String(value);
}
hasBilling = true;
}
}

if (headers['x-ratelimit-limit']) {
billing.rate_limit = headers['x-ratelimit-limit'];
billing.rate_remaining = headers['x-ratelimit-remaining'];
billing.rate_reset = headers['x-ratelimit-reset'];
hasBilling = true;
}

return hasBilling ? billing : null;
}

module.exports = {
extractBillingHeaders,
};
107 changes: 107 additions & 0 deletions containers/api-proxy/deprecated-header-tracker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
'use strict';

const { logRequest } = require('./logging');

/** Map of headerName → Set of rejected values, learned from upstream 400 responses. */
const deprecatedHeaderValues = new Map();
const MAX_CACHED_VALUES_PER_HEADER = 200;

/**
* Pattern to detect header-value rejection errors from Anthropic.
* Matches: Unexpected value(s) `<value>` for the `<header>` header
*/
const DEPRECATED_HEADER_PATTERN = /Unexpected value\(s\)\s+`([^`]+)`\s+for the `([^`]+)` header/;

function normalizeHeaderValue(value) {
if (!value) return '';
return Array.isArray(value) ? value.join(',') : String(value);
}

function splitHeaderValue(value) {
return normalizeHeaderValue(value).split(',').map(s => s.trim()).filter(Boolean);
}

function updateHeader(headers, headerName, values) {
if (!values.length) {
delete headers[headerName];
return;
}
headers[headerName] = values.join(',');
}

function stripValuesFromHeader(headers, headerName, valuesToStrip) {
if (!headers[headerName] || !valuesToStrip.size) return null;
const existingValues = splitHeaderValue(headers[headerName]);
if (!existingValues.length) {
delete headers[headerName];
return { removed: [], remaining: [] };
}
const remaining = existingValues.filter(value => !valuesToStrip.has(value));
const removed = existingValues.filter(value => valuesToStrip.has(value));
if (!removed.length) return null;
updateHeader(headers, headerName, remaining);
return { removed, remaining };
}

function getDeprecatedValuesForHeader(headerName) {
if (!deprecatedHeaderValues.has(headerName)) {
deprecatedHeaderValues.set(headerName, new Set());
}
return deprecatedHeaderValues.get(headerName);
}

function maybeStripLearnedHeaderValues(headers, requestId, provider) {
for (const [headerName, rejectedValues] of deprecatedHeaderValues) {
if (!headers[headerName] || !rejectedValues.size) continue;
const stripped = stripValuesFromHeader(headers, headerName, rejectedValues);
if (!stripped) continue;
logRequest('warn', 'deprecated_header_stripped', {
request_id: requestId,
provider,
header: headerName,
mode: 'cached',
removed_values: stripped.removed,
remaining_values: stripped.remaining,
message: `Removed deprecated ${headerName} values learned from prior upstream 400 responses`,
});
}
}

function parseDeprecatedHeaderFromBody(body) {
const match = body.toString('utf8').match(DEPRECATED_HEADER_PATTERN);
if (!match) return null;
return { value: match[1].trim(), header: match[2].trim() };
}

function learnAndStripDeprecatedHeaderValue(headers, headerName, deprecatedValue, requestId, provider) {
const rejectedValues = getDeprecatedValuesForHeader(headerName);
rejectedValues.add(deprecatedValue);
if (rejectedValues.size > MAX_CACHED_VALUES_PER_HEADER) {
const oldest = rejectedValues.values().next().value;
if (oldest !== undefined) rejectedValues.delete(oldest);
}
const stripped = stripValuesFromHeader(headers, headerName, new Set([deprecatedValue]));
if (!stripped) return null;
logRequest('warn', 'deprecated_header_stripped', {
request_id: requestId,
provider,
header: headerName,
mode: 'retry',
removed_values: stripped.removed,
remaining_values: stripped.remaining,
message: `Removed deprecated ${headerName} value rejected by upstream: ${deprecatedValue}`,
});
return stripped;
}

function resetDeprecatedHeaderValuesForTests() {
deprecatedHeaderValues.clear();
}

module.exports = {
getDeprecatedValuesForHeader,
maybeStripLearnedHeaderValues,
parseDeprecatedHeaderFromBody,
learnAndStripDeprecatedHeaderValue,
resetDeprecatedHeaderValuesForTests,
};
Loading
Loading