diff --git a/packages/gateway/public/dashboard-v2.html b/packages/gateway/public/dashboard-v2.html
index e328140..2b82e81 100644
--- a/packages/gateway/public/dashboard-v2.html
+++ b/packages/gateway/public/dashboard-v2.html
@@ -993,6 +993,7 @@
.req-status.approved { color: var(--ok); border-color: rgba(21,128,61,0.24); }
.req-status.error, .req-status.rejected { color: var(--err); border-color: rgba(180,35,24,0.24); }
.req-status.warning, .req-status.pending_review { color: var(--warn); border-color: rgba(180,83,9,0.24); }
+ .req-status.bypassed { color: var(--err); border-color: rgba(180,35,24,0.32); }
.client-grid {
display: grid;
@@ -1921,7 +1922,7 @@
${req.request_id.substring(0, 14)}…
${escapeHtml(req.caller)}
${req.model}
- ${req.status}
+ ${formatRequestStatus(req)}
${formatNumber(req.compression_tokens_before ?? req.tokens_in ?? 0)}
${formatNumber(req.compression_tokens_after ?? req.tokens_in ?? 0)}
${formatSavedTokens(req.compression_tokens_saved ?? 0)}
@@ -1942,6 +1943,7 @@
}
function formatCompression(req) {
+ if (String(req.compression_mode || '').startsWith('usage-import:')) return 'bypassed';
const mode = String(req.compression_mode || 'none:none').split(':').pop() || 'none';
const pct = Number(req.compression_savings_pct || 0);
if (!req.compression_mode) return 'not tracked';
@@ -1949,6 +1951,10 @@
return `${escapeHtml(mode)} · ${pct.toFixed(1)}%`;
}
+ function formatRequestStatus(req) {
+ return String(req.request_id || '').startsWith('usage-import:') ? 'bypassed' : escapeHtml(req.status);
+ }
+
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
}
diff --git a/packages/gateway/public/dashboard.html b/packages/gateway/public/dashboard.html
index 875556f..60ba8c3 100644
--- a/packages/gateway/public/dashboard.html
+++ b/packages/gateway/public/dashboard.html
@@ -1047,6 +1047,7 @@
.req-status.approved { color: var(--ok); border-color: rgba(21,128,61,0.24); }
.req-status.error, .req-status.rejected { color: var(--err); border-color: rgba(180,35,24,0.24); }
.req-status.warning, .req-status.pending_review { color: var(--warn); border-color: rgba(180,83,9,0.24); }
+ .req-status.bypassed { color: var(--err); border-color: rgba(180,35,24,0.32); }
.client-grid {
display: grid;
@@ -2228,7 +2229,7 @@
${req.request_id.substring(0, 14)}…
${escapeHtml(req.caller)}
${req.model}
- ${req.status}
+ ${formatRequestStatus(req)}
${formatNumber(req.compression_tokens_before ?? req.tokens_in ?? 0)}
${formatNumber(req.compression_tokens_after ?? req.tokens_in ?? 0)}
${formatSavedTokens(req.compression_tokens_saved ?? 0)}
@@ -2249,6 +2250,7 @@
}
function formatCompression(req) {
+ if (String(req.compression_mode || '').startsWith('usage-import:')) return 'bypassed';
const mode = String(req.compression_mode || 'none:none').split(':').pop() || 'none';
const pct = Number(req.compression_savings_pct || 0);
if (!req.compression_mode) return 'not tracked';
@@ -2256,6 +2258,10 @@
return `${escapeHtml(mode)} · ${pct.toFixed(1)}%`;
}
+ function formatRequestStatus(req) {
+ return String(req.request_id || '').startsWith('usage-import:') ? 'bypassed' : escapeHtml(req.status);
+ }
+
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
}
diff --git a/packages/gateway/src/modules/__tests__/transparent-subscription-proxy.test.ts b/packages/gateway/src/modules/__tests__/transparent-subscription-proxy.test.ts
new file mode 100644
index 0000000..79c5a2c
--- /dev/null
+++ b/packages/gateway/src/modules/__tests__/transparent-subscription-proxy.test.ts
@@ -0,0 +1,37 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { transformSubscriptionPayload } from '../transparent-subscription-proxy.js';
+
+afterEach(() => {
+ delete process.env['REDACT_PII_MODE'];
+});
+
+describe('transparent subscription payload protection', () => {
+ it('redacts identifiers and compresses long text without changing protocol fields', () => {
+ process.env['REDACT_PII_MODE'] = 'always';
+ const email = ['person', 'example.org'].join('@');
+ const payload = {
+ model: 'subscription-test-model',
+ input: [{
+ type: 'message',
+ role: 'user',
+ content: [{ type: 'input_text', text: `${email}\n${'context line\n'.repeat(6000)}` }],
+ }],
+ tools: [{ type: 'function', name: 'read_file' }],
+ };
+
+ const result = transformSubscriptionPayload(payload, {
+ providerName: 'codex-subscription',
+ caller: 'unit-client',
+ });
+ const transformed = result.payload as typeof payload;
+ const text = transformed.input[0]?.content[0]?.text ?? '';
+
+ expect(result.compressionApplied).toBe(true);
+ expect(result.tokensAfter).toBeLessThan(result.tokensBefore);
+ expect(result.piiRedactions).toBe(1);
+ expect(text).not.toContain(email);
+ expect(text).toContain('');
+ expect(text).toContain('ctxlean omitted');
+ expect(transformed.tools).toEqual(payload.tools);
+ });
+});
diff --git a/packages/gateway/src/modules/request-logger.ts b/packages/gateway/src/modules/request-logger.ts
index ee281fc..2a5d452 100644
--- a/packages/gateway/src/modules/request-logger.ts
+++ b/packages/gateway/src/modules/request-logger.ts
@@ -143,8 +143,7 @@ export class RequestLogger {
LEFT JOIN LATERAL (
SELECT mode, tokens_before, tokens_after, savings_pct
FROM tokenvault_metrics
- WHERE tool_used = 'gateway'
- AND file_path = rt.request_id
+ WHERE file_path = rt.request_id
ORDER BY created_at DESC
LIMIT 1
) tv ON true
diff --git a/packages/gateway/src/modules/transparent-subscription-proxy.ts b/packages/gateway/src/modules/transparent-subscription-proxy.ts
new file mode 100644
index 0000000..8d03617
--- /dev/null
+++ b/packages/gateway/src/modules/transparent-subscription-proxy.ts
@@ -0,0 +1,260 @@
+import type { FastifyReply, FastifyRequest } from 'fastify';
+import { Readable } from 'stream';
+import { compressContext } from './context-compressor.js';
+import { redactPii, getRedactMode, shouldRedactFor } from './pii-redaction.js';
+import { createRequestLogger } from './request-logger.js';
+import { getPool } from '../db/client.js';
+import { logger } from '../observability/logger.js';
+import { logCompressionMetric } from '../utils/tokenvault-hooks.js';
+
+export type TransparentSubscriptionTarget = 'anthropic' | 'codex';
+
+interface TransformStats {
+ payload: unknown;
+ tokensBefore: number;
+ tokensAfter: number;
+ compressionApplied: boolean;
+ piiRedactions: number;
+}
+
+interface ProxyOptions {
+ target: TransparentSubscriptionTarget;
+ upstreamPath: string;
+ trackRequest?: boolean;
+}
+
+const TEXT_FIELDS = new Set([
+ 'content',
+ 'input',
+ 'input_text',
+ 'output',
+ 'output_text',
+ 'result',
+ 'system',
+ 'text',
+]);
+
+const INTERNAL_HEADERS = new Set([
+ 'connection',
+ 'content-length',
+ 'host',
+ 'transfer-encoding',
+ 'x-forwarded-for',
+ 'x-forwarded-host',
+ 'x-forwarded-proto',
+ 'x-llm-gateway-key',
+ 'x-llm-gateway-upstream',
+ 'x-llm-interceptor-caller',
+ 'x-llm-request-id',
+]);
+
+const RESPONSE_HEADERS = new Set([
+ 'cache-control',
+ 'content-type',
+ 'openai-processing-ms',
+ 'request-id',
+ 'x-request-id',
+ 'x-ratelimit-limit-requests',
+ 'x-ratelimit-limit-tokens',
+ 'x-ratelimit-remaining-requests',
+ 'x-ratelimit-remaining-tokens',
+ 'x-ratelimit-reset-requests',
+ 'x-ratelimit-reset-tokens',
+]);
+
+function estimatePayloadTokens(payload: unknown): number {
+ return Math.max(1, Math.ceil(JSON.stringify(payload ?? {}).length / 4));
+}
+
+function countRedactions(counts: Record): number {
+ return Object.values(counts).reduce((total, count) => total + count, 0);
+}
+
+export function transformSubscriptionPayload(
+ payload: unknown,
+ options: { providerName: string; caller: string },
+): TransformStats {
+ const tokensBefore = estimatePayloadTokens(payload);
+ let compressionApplied = false;
+ let piiRedactions = 0;
+ const redact = shouldRedactFor(getRedactMode(), options.providerName, options.caller);
+
+ const visit = (value: unknown, parentField?: string): unknown => {
+ if (typeof value === 'string') {
+ if (!parentField || !TEXT_FIELDS.has(parentField)) return value;
+ const redacted = redact ? redactPii(value) : null;
+ if (redacted) piiRedactions += countRedactions(redacted.counts);
+ const protectedText = redacted?.redacted ?? value;
+ const compressed = compressContext(protectedText, { enabled: true, mode: 'structured-safe' });
+ compressionApplied ||= compressed.applied;
+ return compressed.input;
+ }
+
+ if (Array.isArray(value)) return value.map((item) => visit(item, parentField));
+ if (!value || typeof value !== 'object') return value;
+
+ return Object.fromEntries(
+ Object.entries(value as Record).map(([key, child]) => [key, visit(child, key)])
+ );
+ };
+
+ const transformed = visit(payload);
+ return {
+ payload: transformed,
+ tokensBefore,
+ tokensAfter: estimatePayloadTokens(transformed),
+ compressionApplied,
+ piiRedactions,
+ };
+}
+
+function requestHeader(request: FastifyRequest, name: string): string | undefined {
+ const value = request.headers[name];
+ return Array.isArray(value) ? value[0] : value;
+}
+
+export function transparentTargetFromRequest(request: FastifyRequest): TransparentSubscriptionTarget | null {
+ const target = requestHeader(request, 'x-llm-gateway-upstream')?.toLowerCase();
+ return target === 'anthropic' || target === 'codex' ? target : null;
+}
+
+function upstreamBase(target: TransparentSubscriptionTarget): string {
+ if (target === 'anthropic') {
+ return (process.env['ANTHROPIC_UPSTREAM_URL'] ?? 'https://api.anthropic.com/v1').replace(/\/+$/, '');
+ }
+ return (process.env['CODEX_UPSTREAM_URL'] ?? 'https://chatgpt.com/backend-api/codex').replace(/\/+$/, '');
+}
+
+function forwardedHeaders(request: FastifyRequest): Record {
+ const headers: Record = {};
+ for (const [name, value] of Object.entries(request.headers)) {
+ if (INTERNAL_HEADERS.has(name) || name.startsWith('cf-')) continue;
+ const first = Array.isArray(value) ? value[0] : value;
+ if (first !== undefined) headers[name] = first;
+ }
+ headers['content-type'] = 'application/json';
+ return headers;
+}
+
+function safeRequestId(request: FastifyRequest, target: TransparentSubscriptionTarget): string {
+ const provided = requestHeader(request, 'x-llm-request-id');
+ if (provided && /^[A-Za-z0-9:._-]{8,220}$/.test(provided)) return provided;
+ return `${target}-proxy:${Date.now()}:${Math.random().toString(36).slice(2, 9)}`;
+}
+
+async function trackProxyRequest(
+ callId: string,
+ caller: string,
+ model: string,
+ target: TransparentSubscriptionTarget,
+ status: 'approved' | 'error',
+ latencyMs: number,
+ transform: TransformStats,
+): Promise {
+ const db = getPool();
+ const saved = Math.max(0, transform.tokensBefore - transform.tokensAfter);
+ await logCompressionMetric(db, {
+ filePath: callId,
+ mode: transform.compressionApplied ? 'structured:head-tail-excerpt' : 'structured:none',
+ tokensBefore: transform.tokensBefore,
+ tokensAfter: transform.tokensAfter,
+ savingsPct: transform.tokensBefore > 0 ? Math.round((saved / transform.tokensBefore) * 10_000) / 100 : 0,
+ toolUsed: 'gateway',
+ });
+ await createRequestLogger(db).logRequest(
+ callId,
+ caller,
+ `${target}_transparent_bridge`,
+ model,
+ status,
+ transform.tokensAfter,
+ 0,
+ 0,
+ latencyMs,
+ 0,
+ transform.compressionApplied,
+ );
+}
+
+export async function proxyTransparentSubscription(
+ request: FastifyRequest,
+ reply: FastifyReply,
+ options: ProxyOptions,
+): Promise {
+ const startedAt = Date.now();
+ const caller = requestHeader(request, 'x-llm-interceptor-caller')
+ ?? requestHeader(request, 'x-caller-id')
+ ?? `${options.target}-transparent-client`;
+ const callId = safeRequestId(request, options.target);
+ const rawPayload = request.body ?? {};
+ const transformed = transformSubscriptionPayload(rawPayload, {
+ providerName: `${options.target}-subscription`,
+ caller,
+ });
+ const model = typeof (rawPayload as Record)?.['model'] === 'string'
+ ? String((rawPayload as Record)['model'])
+ : options.target;
+ const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '';
+ const targetUrl = `${upstreamBase(options.target)}${options.upstreamPath}${query}`;
+
+ try {
+ const upstream = await fetch(targetUrl, {
+ method: request.method,
+ headers: forwardedHeaders(request),
+ body: request.method === 'GET' || request.method === 'HEAD'
+ ? undefined
+ : JSON.stringify(transformed.payload),
+ });
+
+ reply.status(upstream.status).header('X-LLM-Gateway-Request-ID', callId);
+ for (const [name, value] of upstream.headers.entries()) {
+ if (RESPONSE_HEADERS.has(name.toLowerCase())) reply.header(name, value);
+ }
+
+ if (options.trackRequest) {
+ void trackProxyRequest(
+ callId,
+ caller,
+ model,
+ options.target,
+ upstream.ok ? 'approved' : 'error',
+ Date.now() - startedAt,
+ transformed,
+ ).catch((error) => logger.warn({ error, callId }, 'Transparent bridge tracking failed'));
+ }
+
+ logger.info({
+ callId,
+ caller,
+ target: options.target,
+ model,
+ statusCode: upstream.status,
+ tokensBefore: transformed.tokensBefore,
+ tokensAfter: transformed.tokensAfter,
+ piiRedactions: transformed.piiRedactions,
+ }, 'Transparent subscription request proxied');
+
+ if (!upstream.body) return reply.send();
+ return reply.send(Readable.fromWeb(upstream.body as any));
+ } catch (error) {
+ logger.error({ error, callId, target: options.target }, 'Transparent subscription proxy failed');
+ if (options.trackRequest) {
+ void trackProxyRequest(
+ callId,
+ caller,
+ model,
+ options.target,
+ 'error',
+ Date.now() - startedAt,
+ transformed,
+ ).catch(() => undefined);
+ }
+ return reply.status(502).send({
+ error: {
+ message: 'Subscription upstream unavailable',
+ type: 'upstream_error',
+ code: 502,
+ },
+ });
+ }
+}
diff --git a/packages/gateway/src/routes/__tests__/transparent-subscription-route.test.ts b/packages/gateway/src/routes/__tests__/transparent-subscription-route.test.ts
new file mode 100644
index 0000000..ca8bf5d
--- /dev/null
+++ b/packages/gateway/src/routes/__tests__/transparent-subscription-route.test.ts
@@ -0,0 +1,83 @@
+import Fastify from 'fastify';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { completionRoute } from '../completion.js';
+
+const gatewayKey = ['unit', 'gateway', 'route', 'key'].join('-');
+const providerSession = ['provider', 'session', 'value'].join('-');
+
+beforeEach(() => {
+ process.env['LLM_GATEWAY_API_AUTH_MODE'] = 'enforce';
+ process.env['LLM_GATEWAY_API_KEYS'] = gatewayKey;
+ process.env['REDACT_PII_MODE'] = 'always';
+ process.env['ANTHROPIC_UPSTREAM_URL'] = 'https://example.invalid/v1';
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ delete process.env['LLM_GATEWAY_API_AUTH_MODE'];
+ delete process.env['LLM_GATEWAY_API_KEYS'];
+ delete process.env['REDACT_PII_MODE'];
+ delete process.env['ANTHROPIC_UPSTREAM_URL'];
+});
+
+describe('transparent subscription routes', () => {
+ it('requires the dedicated gateway key', async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ const app = Fastify();
+ await app.register(completionRoute, { prefix: '/v1' });
+
+ const response = await app.inject({
+ method: 'POST',
+ url: '/v1/messages',
+ headers: { 'x-llm-gateway-upstream': 'anthropic' },
+ payload: { model: 'subscription-test-model', max_tokens: 16, messages: [{ role: 'user', content: 'hello' }] },
+ });
+
+ expect(response.statusCode).toBe(401);
+ expect(fetchMock).not.toHaveBeenCalled();
+ await app.close();
+ });
+
+ it('forwards provider auth, strips gateway auth and protects prompt text', async () => {
+ const email = ['route', 'example.org'].join('@');
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({
+ id: 'msg_test',
+ type: 'message',
+ role: 'assistant',
+ model: 'subscription-test-model',
+ content: [{ type: 'text', text: 'ok' }],
+ usage: { input_tokens: 10, output_tokens: 1 },
+ }), { status: 200, headers: { 'content-type': 'application/json' } }));
+ vi.stubGlobal('fetch', fetchMock);
+ const app = Fastify();
+ await app.register(completionRoute, { prefix: '/v1' });
+
+ const response = await app.inject({
+ method: 'POST',
+ url: '/v1/messages',
+ headers: {
+ authorization: `Bearer ${providerSession}`,
+ 'x-llm-gateway-key': gatewayKey,
+ 'x-llm-gateway-upstream': 'anthropic',
+ 'anthropic-version': '2023-06-01',
+ },
+ payload: {
+ model: 'subscription-test-model',
+ max_tokens: 16,
+ messages: [{ role: 'user', content: `Contact ${email}` }],
+ },
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
+ const headers = init.headers as Record;
+ expect(url).toBe('https://example.invalid/v1/messages');
+ expect(headers.authorization).toBe(`Bearer ${providerSession}`);
+ expect(headers['x-llm-gateway-key']).toBeUndefined();
+ expect(String(init.body)).not.toContain(email);
+ expect(String(init.body)).toContain('');
+ await app.close();
+ });
+});
diff --git a/packages/gateway/src/routes/completion.ts b/packages/gateway/src/routes/completion.ts
index 9f202a7..31de55b 100644
--- a/packages/gateway/src/routes/completion.ts
+++ b/packages/gateway/src/routes/completion.ts
@@ -68,6 +68,11 @@ import { runPreComplete, runPostComplete } from '../modules/plugin-system.js';
import { getAdaptiveRecommendation } from '../modules/adaptive-routing.js';
import { guardOutputStream, getOutputDefenseMode } from '../modules/output-defense.js';
import { callPromptGuard, isPromptGuardConfigured, getPromptGuardThreshold, getPromptGuardMinLen } from '../modules/prompt-guard-client.js';
+import {
+ proxyTransparentSubscription,
+ transparentTargetFromRequest,
+} from '../modules/transparent-subscription-proxy.js';
+import { authorizeGatewayRequest } from '../security/tenant-auth.js';
// // Disable Ollama-dependent scanners (sentinel, constitutional, embedding, attention)
// // to keep gateway scans fast and dependency-free
@@ -121,6 +126,11 @@ const CompletionRequestSchema = z.object({
type CompletionRequest = z.infer;
+const AGENT_BODY_LIMIT_BYTES = Math.min(
+ 64 * 1024 * 1024,
+ Math.max(1024 * 1024, Number(process.env['GATEWAY_AGENT_BODY_LIMIT_BYTES'] ?? 16 * 1024 * 1024)),
+);
+
function shouldBypassResponseCache(caller: string): boolean {
const normalized = caller.toLowerCase();
return normalized.includes('claude-code')
@@ -1523,7 +1533,7 @@ export async function completionRoute(fastify: FastifyInstance): Promise {
return reply.send(listGatewayModels());
});
- fastify.post('/chat/completions', { config: { rateLimit: false } }, async (request: FastifyRequest, reply: FastifyReply) => {
+ fastify.post('/chat/completions', { bodyLimit: AGENT_BODY_LIMIT_BYTES, config: { rateLimit: false } }, async (request: FastifyRequest, reply: FastifyReply) => {
const startMs = Date.now();
const parsed = OpenAIChatCompletionRequestSchema.safeParse(request.body);
if (!parsed.success) {
@@ -1538,6 +1548,9 @@ export async function completionRoute(fastify: FastifyInstance): Promise {
const callId = `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const requestedSubscriptionId = requestedSubscriptionFromHeaders(request);
+ if (requestedSubscriptionId || subscriptionBridgeTargetForModel(parsed.data.model)) {
+ if (!await authorizeGatewayRequest(request, reply)) return reply;
+ }
const { caller: bridgeCaller } = detectCaller(request, 'openai-compatible', parsed.data.user);
const subscriptionBridgeResult = await callSubscriptionBridgeChatCompletion(parsed.data, startMs, callId, requestedSubscriptionId, bridgeCaller);
if (requestedSubscriptionId && !subscriptionBridgeResult) {
@@ -1581,8 +1594,21 @@ export async function completionRoute(fastify: FastifyInstance): Promise {
});
// Anthropic Messages API compatibility — accept @anthropic-ai/sdk traffic.
- fastify.post('/messages', { config: { rateLimit: false } }, async (request: FastifyRequest, reply: FastifyReply) => {
+ fastify.post('/messages', { bodyLimit: AGENT_BODY_LIMIT_BYTES, config: { rateLimit: false } }, async (request: FastifyRequest, reply: FastifyReply) => {
const startMs = Date.now();
+ const transparentTarget = transparentTargetFromRequest(request);
+ if (transparentTarget) {
+ if (transparentTarget !== 'anthropic') {
+ return reply.status(400).send({ type: 'error', error: { type: 'invalid_request_error', message: 'invalid transparent upstream for Messages API' } });
+ }
+ if (!await authorizeGatewayRequest(request, reply)) return reply;
+ return proxyTransparentSubscription(request, reply, {
+ target: 'anthropic',
+ upstreamPath: '/messages',
+ trackRequest: false,
+ });
+ }
+
const parsed = AnthropicMessagesRequestSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
@@ -1594,6 +1620,10 @@ export async function completionRoute(fastify: FastifyInstance): Promise {
});
}
+ if (modelToSubscriptionId(parsed.data.model) === 'claude-code') {
+ if (!await authorizeGatewayRequest(request, reply)) return reply;
+ }
+
const callId = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const gatewayRequest = anthropicRequestToGatewayRequest(parsed.data, request);
const result = await executeCompletion(gatewayRequest, startMs, callId);
@@ -1622,8 +1652,33 @@ export async function completionRoute(fastify: FastifyInstance): Promise {
return reply.status(200).send(response);
});
- fastify.post('/responses', { config: { rateLimit: false } }, async (request: FastifyRequest, reply: FastifyReply) => {
+ fastify.post('/messages/count_tokens', { bodyLimit: AGENT_BODY_LIMIT_BYTES, config: { rateLimit: false } }, async (request: FastifyRequest, reply: FastifyReply) => {
+ if (transparentTargetFromRequest(request) !== 'anthropic') {
+ return reply.status(400).send({ type: 'error', error: { type: 'invalid_request_error', message: 'transparent Anthropic bridge required' } });
+ }
+ if (!await authorizeGatewayRequest(request, reply)) return reply;
+ return proxyTransparentSubscription(request, reply, {
+ target: 'anthropic',
+ upstreamPath: '/messages/count_tokens',
+ trackRequest: false,
+ });
+ });
+
+ fastify.post('/responses', { bodyLimit: AGENT_BODY_LIMIT_BYTES, config: { rateLimit: false } }, async (request: FastifyRequest, reply: FastifyReply) => {
const startMs = Date.now();
+ const transparentTarget = transparentTargetFromRequest(request);
+ if (transparentTarget) {
+ if (transparentTarget !== 'codex') {
+ return reply.status(400).send({ error: { message: 'invalid transparent upstream for Responses API', type: 'invalid_request_error', code: 400 } });
+ }
+ if (!await authorizeGatewayRequest(request, reply)) return reply;
+ return proxyTransparentSubscription(request, reply, {
+ target: 'codex',
+ upstreamPath: '/responses',
+ trackRequest: true,
+ });
+ }
+
const parsed = OpenAIResponsesRequestSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
@@ -1643,6 +1698,7 @@ export async function completionRoute(fastify: FastifyInstance): Promise {
// the right auth. Route them straight to the local codex-bridge
// (local bridge process) which speaks codex-cli over OAuth.
if (isCodexBridgeModel(parsed.data.model)) {
+ if (!await authorizeGatewayRequest(request, reply)) return reply;
try {
const bridgeUrl = process.env['CODEX_BRIDGE_URL'] ?? 'http://localhost:3253';
const bridgeCaller = (request.headers['x-llm-interceptor-caller'] as string) || 'codex-app';
@@ -1790,6 +1846,18 @@ data: [DONE]
return reply.send(response);
});
+ fastify.post('/responses/compact', { bodyLimit: AGENT_BODY_LIMIT_BYTES, config: { rateLimit: false } }, async (request: FastifyRequest, reply: FastifyReply) => {
+ if (transparentTargetFromRequest(request) !== 'codex') {
+ return reply.status(400).send({ error: { message: 'transparent Codex bridge required', type: 'invalid_request_error', code: 400 } });
+ }
+ if (!await authorizeGatewayRequest(request, reply)) return reply;
+ return proxyTransparentSubscription(request, reply, {
+ target: 'codex',
+ upstreamPath: '/responses/compact',
+ trackRequest: false,
+ });
+ });
+
// ─── Multi-Model Race Mode endpoint ────────────────────────────────────
// Runs the same prompt against multiple models in parallel; returns
// according to `strategy` (first | best | consensus). Audits each
diff --git a/packages/gateway/src/routes/dashboard.ts b/packages/gateway/src/routes/dashboard.ts
index 901fe2d..372069c 100644
--- a/packages/gateway/src/routes/dashboard.ts
+++ b/packages/gateway/src/routes/dashboard.ts
@@ -1753,22 +1753,30 @@ export async function dashboardRoute(fastify: FastifyInstance): Promise {
// Upsert by request_id (one row per caller/model/day): re-reporting an
// in-progress day updates its totals instead of creating duplicates.
const updated = await db.query(
- `UPDATE request_tracking SET tokens_in=$1, tokens_out=$2, cost_usd=$3, created_at=$4 WHERE request_id=$5`,
+ `UPDATE request_tracking SET tokens_in=$1, tokens_out=$2, cost_usd=$3, created_at=$4, status='warning', task_type='usage_import' WHERE request_id=$5`,
[tokensIn, tokensOut, costUsd, usedAt, requestId]
);
if (updated.rowCount === 0) {
const requestLogger = createRequestLogger(db);
- await requestLogger.logRequest(requestId, caller, 'usage_import', model, 'approved', tokensIn, tokensOut, costUsd, 0);
+ await requestLogger.logRequest(requestId, caller, 'usage_import', model, 'warning', tokensIn, tokensOut, costUsd, 0);
await db.query(`UPDATE request_tracking SET created_at=$1 WHERE request_id=$2`, [usedAt, requestId]);
}
- await logCompressionMetric(db, {
- filePath: requestId,
- mode: 'usage-import:none',
- tokensBefore: tokensIn,
- tokensAfter: tokensIn,
- savingsPct: 0,
- toolUsed: 'gateway',
- });
+ const updatedMetric = await db.query(
+ `UPDATE tokenvault_metrics
+ SET mode='usage-import:none', tokens_before=$2, tokens_after=$2, savings_pct=0, created_at=NOW(), tool_used='usage-import'
+ WHERE id=(SELECT id FROM tokenvault_metrics WHERE file_path=$1 ORDER BY created_at DESC LIMIT 1)`,
+ [requestId, tokensIn]
+ );
+ if (updatedMetric.rowCount === 0) {
+ await logCompressionMetric(db, {
+ filePath: requestId,
+ mode: 'usage-import:none',
+ tokensBefore: tokensIn,
+ tokensAfter: tokensIn,
+ savingsPct: 0,
+ toolUsed: 'usage-import',
+ });
+ }
return reply.status(200).send({ success: true, imported: { caller, model, day, tokensIn, tokensOut, costUsd, usedAt } });
} catch (error) {
logger.error({ error }, 'Failed to import usage report');
diff --git a/packages/gateway/src/routes/subscriptions.ts b/packages/gateway/src/routes/subscriptions.ts
index 11b04af..981e6e1 100644
--- a/packages/gateway/src/routes/subscriptions.ts
+++ b/packages/gateway/src/routes/subscriptions.ts
@@ -14,6 +14,7 @@ import {
import { getPublicSettings, saveSettings } from '../modules/settings-store.js';
import { getAllProviders, getAvailableProviders } from '../pipeline/external-providers.js';
import { logger } from '../observability/logger.js';
+import { requireGatewayApiKey } from '../security/tenant-auth.js';
interface SubscriptionBridgeBody {
subscription_id?: SubscriptionId;
@@ -126,7 +127,7 @@ function buildAccessCard(
` -H "X-Caller-ID: pilot-client"`,
` -d '{"model":"${defaultModel}","messages":[{"role":"user","content":"gateway bridge smoke"}]}'`,
].join(' \\\n'),
- note: 'Use a gateway API key/admin token from your deployment. OAuth/subscription secrets stay inside the bridge process and are never exposed here.',
+ note: 'Use a dedicated gateway API key from your deployment. OAuth/subscription secrets stay inside the bridge process and are never exposed here.',
};
}
@@ -329,6 +330,7 @@ async function postBridgeChat(
export async function subscriptionsRoute(fastify: FastifyInstance): Promise {
const auth = { preHandler: requireDashboardAuth };
+ const apiAuth = { preHandler: requireGatewayApiKey };
fastify.get('/api/subscriptions', auth, async (request: FastifyRequest, reply: FastifyReply) => {
const query = request.query as { search?: string; q?: string };
@@ -521,7 +523,7 @@ export async function subscriptionsRoute(fastify: FastifyInstance): Promise {
+ fastify.post('/api/subscriptions/:subscription_id/v1/chat/completions', apiAuth, async (request: FastifyRequest, reply: FastifyReply) => {
const params = request.params as SubscriptionChatParams;
const status = await getSubscriptionStatusOrReply(params.subscription_id, reply);
if (!status) return reply;
@@ -542,6 +544,8 @@ export async function subscriptionsRoute(fastify: FastifyInstance): Promise {
+ fastify.post(
+ '/api/tracking/report',
+ { preHandler: requireGatewayApiKey },
+ async (request: FastifyRequest, reply: FastifyReply) => {
+ const parsed = BridgeTrackingReportSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return reply.status(400).send({ success: false, error: parsed.error.errors[0]?.message ?? 'invalid report' });
+ }
+
+ const report = parsed.data;
+ const requestId = safeRequestId(report.request_id);
+ const before = report.compression_tokens_before ?? report.tokens_in;
+ const after = report.compression_tokens_after ?? report.tokens_in;
+ const saved = Math.max(0, before - after);
+ const db = getPool();
+
+ try {
+ const existing = await db.query('SELECT 1 FROM request_tracking WHERE request_id=$1 LIMIT 1', [requestId]);
+ if (existing.rowCount === 0) {
+ await createRequestLogger(db).logRequest(
+ requestId,
+ report.caller,
+ `subscription_bridge:${report.source}`.slice(0, 50),
+ report.model,
+ 'approved',
+ report.tokens_in,
+ report.tokens_out,
+ 0,
+ report.latency_ms,
+ 0,
+ saved > 0,
+ );
+ await logCompressionMetric(db, {
+ filePath: requestId,
+ mode: report.compression_mode ?? (saved > 0 ? 'bridge:compressed' : 'bridge:none'),
+ tokensBefore: before,
+ tokensAfter: after,
+ savingsPct: before > 0 ? Math.round((saved / before) * 10_000) / 100 : 0,
+ toolUsed: 'gateway',
+ });
+ }
+ return reply.status(200).send({ success: true, requestId, duplicate: existing.rowCount !== 0 });
+ } catch (error) {
+ logger.error({ error, requestId }, 'Bridge tracking report failed');
+ return reply.status(500).send({ success: false, error: 'tracking report failed' });
+ }
+ },
+ );
+}
diff --git a/packages/gateway/src/security/__tests__/tenant-auth.test.ts b/packages/gateway/src/security/__tests__/tenant-auth.test.ts
new file mode 100644
index 0000000..aed686d
--- /dev/null
+++ b/packages/gateway/src/security/__tests__/tenant-auth.test.ts
@@ -0,0 +1,42 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import type { FastifyRequest } from 'fastify';
+import {
+ gatewayApiKeyMatches,
+ getGatewayApiAuthMode,
+ getPresentedGatewayApiKey,
+} from '../tenant-auth.js';
+
+function requestWithHeaders(headers: Record): FastifyRequest {
+ return { headers } as unknown as FastifyRequest;
+}
+
+afterEach(() => {
+ delete process.env['LLM_GATEWAY_API_AUTH_MODE'];
+});
+
+describe('gateway API authentication', () => {
+ it('defaults to off and accepts explicit rollout modes', () => {
+ expect(getGatewayApiAuthMode()).toBe('off');
+ process.env['LLM_GATEWAY_API_AUTH_MODE'] = 'monitor';
+ expect(getGatewayApiAuthMode()).toBe('monitor');
+ process.env['LLM_GATEWAY_API_AUTH_MODE'] = 'enforce';
+ expect(getGatewayApiAuthMode()).toBe('enforce');
+ });
+
+ it('reads the dedicated bridge header before bearer authorization', () => {
+ const dedicated = ['unit', 'gateway', 'dedicated'].join('-');
+ const bearer = ['unit', 'gateway', 'bearer'].join('-');
+ const request = requestWithHeaders({
+ 'x-llm-gateway-key': dedicated,
+ authorization: `Bearer ${bearer}`,
+ });
+ expect(getPresentedGatewayApiKey(request)).toBe(dedicated);
+ });
+
+ it('matches configured keys without accepting missing or partial values', () => {
+ const configured = ['unit', 'gateway', 'key', 'alpha'].join('-');
+ expect(gatewayApiKeyMatches(configured, [configured])).toBe(true);
+ expect(gatewayApiKeyMatches(`${configured}-partial`, [configured])).toBe(false);
+ expect(gatewayApiKeyMatches(undefined, [configured])).toBe(false);
+ });
+});
diff --git a/packages/gateway/src/security/tenant-auth.ts b/packages/gateway/src/security/tenant-auth.ts
index c8ff86c..a76505f 100644
--- a/packages/gateway/src/security/tenant-auth.ts
+++ b/packages/gateway/src/security/tenant-auth.ts
@@ -1,9 +1,93 @@
import fp from 'fastify-plugin';
-import type { FastifyInstance } from 'fastify';
+import { timingSafeEqual } from 'crypto';
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { logger } from '../observability/logger.js';
+
+export type GatewayApiAuthMode = 'off' | 'monitor' | 'enforce';
+
+export function getGatewayApiAuthMode(): GatewayApiAuthMode {
+ const mode = (process.env['LLM_GATEWAY_API_AUTH_MODE'] ?? 'off').trim().toLowerCase();
+ if (mode === 'monitor' || mode === 'enforce') return mode;
+ return 'off';
+}
+
+export function getConfiguredGatewayApiKeys(): string[] {
+ return (process.env['LLM_GATEWAY_API_KEYS'] ?? '')
+ .split(',')
+ .map((key) => key.trim())
+ .filter(Boolean);
+}
+
+function firstHeader(value: string | string[] | undefined): string | undefined {
+ return Array.isArray(value) ? value[0] : value;
+}
+
+export function getPresentedGatewayApiKey(request: FastifyRequest): string | undefined {
+ const dedicated = firstHeader(request.headers['x-llm-gateway-key']);
+ if (dedicated?.trim()) return dedicated.trim();
+
+ const authorization = firstHeader(request.headers.authorization);
+ const match = authorization?.match(/^Bearer\s+(.+)$/i);
+ return match?.[1]?.trim();
+}
+
+export function gatewayApiKeyMatches(presented: string | undefined, configuredKeys: readonly string[]): boolean {
+ if (!presented) return false;
+ const candidate = Buffer.from(presented);
+ return configuredKeys.some((configured) => {
+ const expected = Buffer.from(configured);
+ return candidate.length === expected.length && timingSafeEqual(candidate, expected);
+ });
+}
+
+export async function authorizeGatewayRequest(
+ request: FastifyRequest,
+ reply: FastifyReply,
+): Promise {
+ const mode = getGatewayApiAuthMode();
+ if (mode === 'off') return true;
+
+ const configuredKeys = getConfiguredGatewayApiKeys();
+ const valid = gatewayApiKeyMatches(getPresentedGatewayApiKey(request), configuredKeys);
+ if (valid) return true;
+
+ if (mode === 'monitor') {
+ logger.warn({ method: request.method, path: request.url.split('?')[0] }, 'Gateway API key missing or invalid');
+ return true;
+ }
+
+ if (configuredKeys.length === 0) {
+ reply.status(503).send({
+ error: {
+ message: 'Gateway API authentication is not configured',
+ type: 'gateway_auth_configuration_error',
+ code: 503,
+ },
+ });
+ return false;
+ }
+
+ reply.header('WWW-Authenticate', 'Bearer realm="llm-gateway"');
+ reply.status(401).send({
+ error: {
+ message: 'Valid gateway API key required',
+ type: 'authentication_error',
+ code: 401,
+ },
+ });
+ return false;
+}
+
+export async function requireGatewayApiKey(
+ request: FastifyRequest,
+ reply: FastifyReply,
+): Promise {
+ const authorized = await authorizeGatewayRequest(request, reply);
+ if (!authorized) return reply;
+}
async function tenantAuth(_fastify: FastifyInstance): Promise {
- // Tenant auth is intentionally permissive until tenant policies are configured.
- // Admin and dashboard routes keep their own token checks.
+ logger.info({ mode: getGatewayApiAuthMode(), configuredKeys: getConfiguredGatewayApiKeys().length }, 'Gateway API authentication initialised');
}
export default fp(tenantAuth, { name: 'tenant-auth' });
diff --git a/packages/gateway/src/server.ts b/packages/gateway/src/server.ts
index 4c9c9f9..dab62e6 100644
--- a/packages/gateway/src/server.ts
+++ b/packages/gateway/src/server.ts
@@ -3,6 +3,7 @@ import fastifyCors from '@fastify/cors';
import fastifyRateLimit from '@fastify/rate-limit';
import fastifyHelmet from '@fastify/helmet';
import { completionRoute } from './routes/completion.js';
+import { trackingRoute } from './routes/tracking.js';
import { batchRoute } from './routes/batch.js';
import { classifyRoute } from './routes/classify.js';
import { guardRoute } from './routes/guard.js';
@@ -111,7 +112,18 @@ async function buildServer() {
/^https:\/\/.*\.runwerk\.app$/,
],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
- allowedHeaders: ['Content-Type', 'Authorization', 'X-Caller-ID', 'X-Runwerk-Caller', 'X-Runwerk-Privacy', 'X-Runwerk-Tier', 'X-Runwerk-Purpose'],
+ allowedHeaders: [
+ 'Content-Type',
+ 'Authorization',
+ 'X-Caller-ID',
+ 'X-LLM-Gateway-Key',
+ 'X-LLM-Gateway-Upstream',
+ 'X-LLM-Interceptor-Caller',
+ 'X-Runwerk-Caller',
+ 'X-Runwerk-Privacy',
+ 'X-Runwerk-Tier',
+ 'X-Runwerk-Purpose',
+ ],
credentials: true,
});
@@ -132,6 +144,7 @@ async function buildServer() {
await server.register(tenantAuth);
await server.register(internalRoute);
+ await server.register(trackingRoute);
await server.register(completionRoute, { prefix: '/v1' });
await server.register(embeddingsRoute, { prefix: '/v1' });
await server.register(replayRoute, { prefix: '/v1' });
diff --git a/scripts/client/anthropic-interceptor.py b/scripts/client/anthropic-interceptor.py
new file mode 100644
index 0000000..22b37b1
--- /dev/null
+++ b/scripts/client/anthropic-interceptor.py
@@ -0,0 +1,288 @@
+#!/usr/bin/env python3
+"""Fail-closed local subscription companion for Claude Code and Codex."""
+
+import asyncio
+import json
+import math
+import os
+import re
+import subprocess
+import time
+import uuid
+
+import httpx
+from fastapi import FastAPI, Request, Response
+from fastapi.responses import JSONResponse, StreamingResponse
+
+
+GATEWAY_URL = os.getenv("LLM_GATEWAY_URL", "https://llm-gateway.context-x.org").rstrip("/")
+KEYCHAIN_SERVICE = os.getenv("LLM_GATEWAY_KEYCHAIN_SERVICE", "llm-gateway-api-key")
+CLAUDE_CALLER = os.getenv("INTERCEPTOR_CALLER", "claude-code-macbook")
+CODEX_CALLER = os.getenv("CODEX_INTERCEPTOR_CALLER", "codex-macbook")
+MAX_TOOL_CHARS = int(os.getenv("MAX_TOOL_CHARS", "8000"))
+
+_PII = [
+ (re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b"), "[EMAIL]"),
+ (re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), "[IP_ADDRESS]"),
+ (re.compile(r"\b(?:IBAN[:\s]*)?[A-Z]{2}\d{2}[A-Z0-9 ]{10,30}\b"), "[IBAN]"),
+ (re.compile(r"\beyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\b"), "[JWT]"),
+ (re.compile(r"\b(?:sk-proj-|sk-ant-)[A-Za-z0-9_\-]{20,}\b"), "[API_KEY]"),
+ (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[AWS_KEY]"),
+ (re.compile(r"\b(?:ghp_|github_pat_)[A-Za-z0-9_]{20,}\b"), "[GH_TOKEN]"),
+]
+
+
+def gateway_key() -> str:
+ configured = os.getenv("LLM_GATEWAY_API_KEY", "").strip()
+ if configured:
+ return configured
+ try:
+ return subprocess.check_output(
+ ["/usr/bin/security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"],
+ stderr=subprocess.DEVNULL,
+ timeout=5,
+ ).decode().strip()
+ except Exception:
+ return ""
+
+
+def anonymize(text: str) -> str:
+ for pattern, replacement in _PII:
+ text = pattern.sub(replacement, text)
+ return text
+
+
+def anonymize_content(content):
+ if isinstance(content, str):
+ return anonymize(content)
+ if isinstance(content, list):
+ result = []
+ for part in content:
+ if isinstance(part, dict) and part.get("type") == "text":
+ part = {**part, "text": anonymize(str(part.get("text", "")))}
+ result.append(part)
+ return result
+ return content
+
+
+def compress_messages(messages: list) -> list:
+ result = []
+ for original in messages:
+ message = dict(original)
+ content = message.get("content", "")
+ if message.get("role") == "tool" and isinstance(content, str) and len(content) > MAX_TOOL_CHARS:
+ message["content"] = content[:MAX_TOOL_CHARS] + "\n[truncated by subscription companion]"
+ elif message.get("role") == "user" and isinstance(content, list):
+ parts = []
+ for original_part in content:
+ part = dict(original_part) if isinstance(original_part, dict) else original_part
+ if isinstance(part, dict) and part.get("type") == "tool_result":
+ inner = part.get("content", "")
+ if isinstance(inner, str) and len(inner) > MAX_TOOL_CHARS:
+ part["content"] = inner[:MAX_TOOL_CHARS] + "\n[truncated by subscription companion]"
+ elif isinstance(inner, list):
+ trimmed = []
+ for original_inner_part in inner:
+ inner_part = dict(original_inner_part) if isinstance(original_inner_part, dict) else original_inner_part
+ if (
+ isinstance(inner_part, dict)
+ and inner_part.get("type") == "text"
+ and len(str(inner_part.get("text", ""))) > MAX_TOOL_CHARS
+ ):
+ inner_part["text"] = str(inner_part["text"])[:MAX_TOOL_CHARS] + "\n[truncated by subscription companion]"
+ trimmed.append(inner_part)
+ part["content"] = trimmed
+ parts.append(part)
+ message["content"] = parts
+ result.append(message)
+ return result
+
+
+def estimate_tokens(payload) -> int:
+ return max(1, math.ceil(len(json.dumps(payload, ensure_ascii=False)) / 4))
+
+
+def prepare_anthropic_body(raw_body: bytes) -> tuple[bytes, int, int, bool]:
+ data = json.loads(raw_body or b"{}")
+ before = estimate_tokens(data)
+ is_stream = bool(data.get("stream"))
+ if isinstance(data.get("system"), str):
+ data["system"] = anonymize(data["system"])
+ elif isinstance(data.get("system"), list):
+ data["system"] = anonymize_content(data["system"])
+
+ messages = []
+ for original in data.get("messages", []):
+ message = dict(original)
+ message["content"] = anonymize_content(message.get("content", ""))
+ messages.append(message)
+ data["messages"] = compress_messages(messages)
+ after = estimate_tokens(data)
+ return json.dumps(data, ensure_ascii=False).encode(), before, after, is_stream
+
+
+def response_headers(headers: httpx.Headers) -> dict[str, str]:
+ blocked = {"connection", "content-encoding", "content-length", "keep-alive", "transfer-encoding"}
+ return {name: value for name, value in headers.items() if name.lower() not in blocked}
+
+
+async def report_usage(
+ key: str,
+ request_id: str,
+ caller: str,
+ model: str,
+ tokens_in: int,
+ tokens_out: int,
+ latency_ms: int,
+ before: int,
+ after: int,
+) -> None:
+ try:
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ await client.post(
+ f"{GATEWAY_URL}/api/tracking/report",
+ headers={"X-LLM-Gateway-Key": key},
+ json={
+ "request_id": request_id,
+ "caller": caller,
+ "model": model,
+ "tokens_in": tokens_in,
+ "tokens_out": tokens_out,
+ "latency_ms": latency_ms,
+ "source": "local-subscription-companion",
+ "compression_tokens_before": before,
+ "compression_tokens_after": after,
+ "compression_mode": "interceptor:tool-trim" if before > after else "interceptor:none",
+ },
+ )
+ except Exception:
+ return
+
+
+app = FastAPI(title="llm-subscription-companion", docs_url=None, redoc_url=None)
+
+
+@app.get("/health")
+async def health():
+ configured = bool(gateway_key())
+ return JSONResponse(
+ status_code=200 if configured else 503,
+ content={"status": "ok" if configured else "auth_required", "gateway": GATEWAY_URL, "failClosed": True},
+ )
+
+
+@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"])
+async def proxy(request: Request, path: str):
+ key = gateway_key()
+ if not key:
+ return JSONResponse(status_code=503, content={"error": "gateway authentication unavailable"})
+
+ started_at = time.monotonic()
+ raw_body = await request.body()
+ is_codex = path.startswith("openai/")
+ gateway_path = path[len("openai/"):] if is_codex else path
+ target_kind = "codex" if is_codex else "anthropic"
+ caller = CODEX_CALLER if is_codex else CLAUDE_CALLER
+ is_messages = not is_codex and gateway_path == "v1/messages" and request.method == "POST"
+ is_stream = False
+ before = after = estimate_tokens({})
+
+ if is_messages:
+ try:
+ raw_body, before, after, is_stream = prepare_anthropic_body(raw_body)
+ except Exception:
+ return JSONResponse(status_code=400, content={"error": "invalid Anthropic request body"})
+
+ request_id = f"{target_kind}-companion:{int(time.time() * 1000)}:{uuid.uuid4().hex[:8]}"
+ blocked = {"connection", "content-length", "host", "transfer-encoding", "x-llm-gateway-key"}
+ headers = {name: value for name, value in request.headers.items() if name.lower() not in blocked}
+ headers.update({
+ "X-LLM-Gateway-Key": key,
+ "X-LLM-Gateway-Upstream": target_kind,
+ "X-LLM-Interceptor-Caller": caller,
+ "X-LLM-Request-ID": request_id,
+ })
+ target = f"{GATEWAY_URL}/{gateway_path}"
+ params = dict(request.query_params)
+
+ if is_stream:
+ client = httpx.AsyncClient(timeout=300.0)
+ upstream_request = client.build_request(request.method, target, headers=headers, content=raw_body, params=params)
+ upstream = await client.send(upstream_request, stream=True)
+
+ async def stream_response():
+ pending = ""
+ model_name, tokens_in, tokens_out = "unknown", 0, 0
+ try:
+ async for chunk in upstream.aiter_bytes():
+ pending += chunk.decode(errors="ignore")
+ lines = pending.split("\n")
+ pending = lines.pop()
+ for line in lines:
+ if not line.startswith("data: "):
+ continue
+ try:
+ event = json.loads(line[6:])
+ except Exception:
+ continue
+ if event.get("type") == "message_start":
+ message = event.get("message", {})
+ model_name = message.get("model", model_name)
+ tokens_in = message.get("usage", {}).get("input_tokens", tokens_in)
+ elif event.get("type") == "message_delta":
+ tokens_out = event.get("usage", {}).get("output_tokens", tokens_out)
+ yield chunk
+ finally:
+ await upstream.aclose()
+ await client.aclose()
+ if tokens_in or tokens_out:
+ await report_usage(
+ key, request_id, caller, model_name, tokens_in, tokens_out,
+ int((time.monotonic() - started_at) * 1000), before, after,
+ )
+
+ return StreamingResponse(
+ stream_response(),
+ status_code=upstream.status_code,
+ headers=response_headers(upstream.headers),
+ media_type=upstream.headers.get("content-type", "text/event-stream"),
+ )
+
+ async with httpx.AsyncClient(timeout=300.0) as client:
+ upstream = await client.request(
+ request.method,
+ target,
+ headers=headers,
+ content=raw_body if request.method not in {"GET", "HEAD"} else None,
+ params=params,
+ )
+
+ if is_messages and upstream.is_success:
+ try:
+ response_data = upstream.json()
+ usage = response_data.get("usage", {})
+ asyncio.create_task(report_usage(
+ key,
+ request_id,
+ caller,
+ response_data.get("model", "unknown"),
+ int(usage.get("input_tokens", 0)),
+ int(usage.get("output_tokens", 0)),
+ int((time.monotonic() - started_at) * 1000),
+ before,
+ after,
+ ))
+ except Exception:
+ pass
+
+ return Response(
+ content=upstream.content,
+ status_code=upstream.status_code,
+ headers=response_headers(upstream.headers),
+ )
+
+
+if __name__ == "__main__":
+ import uvicorn
+
+ uvicorn.run(app, host="127.0.0.1", port=int(os.getenv("PORT", "3090")), log_level="warning")
diff --git a/start-with-env.sh b/start-with-env.sh
index 9b167cc..9bec1a9 100755
--- a/start-with-env.sh
+++ b/start-with-env.sh
@@ -3,6 +3,7 @@
# Production fix for the recurring PM2 env-drop quirk.
set -a
[ -f /opt/llm-gateway/.env.defense ] && source /opt/llm-gateway/.env.defense
+[ -f /opt/llm-gateway/.env.api ] && source /opt/llm-gateway/.env.api
[ -f /opt/llm-gateway/.env ] && source /opt/llm-gateway/.env
set +a
exec /usr/bin/node /opt/llm-gateway/packages/gateway/scripts/launch.mjs