feat: proxy subscription clients through gateway
All checks were successful
changelog-draft / changelog-draft (push) Successful in 31s

This commit is contained in:
Rene Fichtmueller 2026-07-18 18:39:15 +02:00
parent f720096d3b
commit b8543ec191
15 changed files with 998 additions and 23 deletions

View File

@ -993,6 +993,7 @@
.req-status.approved { color: var(--ok); border-color: rgba(21,128,61,0.24); } .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.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.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 { .client-grid {
display: grid; display: grid;
@ -1921,7 +1922,7 @@
<div title="${req.request_id}">${req.request_id.substring(0, 14)}…</div> <div title="${req.request_id}">${req.request_id.substring(0, 14)}…</div>
<div>${escapeHtml(req.caller)}</div> <div>${escapeHtml(req.caller)}</div>
<div title="${req.model}">${req.model}</div> <div title="${req.model}">${req.model}</div>
<div><span class="req-status ${req.status}">${req.status}</span></div> <div><span class="req-status ${formatRequestStatus(req)}">${formatRequestStatus(req)}</span></div>
<div>${formatNumber(req.compression_tokens_before ?? req.tokens_in ?? 0)}</div> <div>${formatNumber(req.compression_tokens_before ?? req.tokens_in ?? 0)}</div>
<div>${formatNumber(req.compression_tokens_after ?? req.tokens_in ?? 0)}</div> <div>${formatNumber(req.compression_tokens_after ?? req.tokens_in ?? 0)}</div>
<div>${formatSavedTokens(req.compression_tokens_saved ?? 0)}</div> <div>${formatSavedTokens(req.compression_tokens_saved ?? 0)}</div>
@ -1942,6 +1943,7 @@
} }
function formatCompression(req) { 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 mode = String(req.compression_mode || 'none:none').split(':').pop() || 'none';
const pct = Number(req.compression_savings_pct || 0); const pct = Number(req.compression_savings_pct || 0);
if (!req.compression_mode) return 'not tracked'; if (!req.compression_mode) return 'not tracked';
@ -1949,6 +1951,10 @@
return `${escapeHtml(mode)} · ${pct.toFixed(1)}%`; return `${escapeHtml(mode)} · ${pct.toFixed(1)}%`;
} }
function formatRequestStatus(req) {
return String(req.request_id || '').startsWith('usage-import:') ? 'bypassed' : escapeHtml(req.status);
}
function escapeHtml(s) { function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c])); return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
} }

View File

@ -1047,6 +1047,7 @@
.req-status.approved { color: var(--ok); border-color: rgba(21,128,61,0.24); } .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.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.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 { .client-grid {
display: grid; display: grid;
@ -2228,7 +2229,7 @@
<div title="${req.request_id}">${req.request_id.substring(0, 14)}…</div> <div title="${req.request_id}">${req.request_id.substring(0, 14)}…</div>
<div>${escapeHtml(req.caller)}</div> <div>${escapeHtml(req.caller)}</div>
<div title="${req.model}">${req.model}</div> <div title="${req.model}">${req.model}</div>
<div><span class="req-status ${req.status}">${req.status}</span></div> <div><span class="req-status ${formatRequestStatus(req)}">${formatRequestStatus(req)}</span></div>
<div>${formatNumber(req.compression_tokens_before ?? req.tokens_in ?? 0)}</div> <div>${formatNumber(req.compression_tokens_before ?? req.tokens_in ?? 0)}</div>
<div>${formatNumber(req.compression_tokens_after ?? req.tokens_in ?? 0)}</div> <div>${formatNumber(req.compression_tokens_after ?? req.tokens_in ?? 0)}</div>
<div>${formatSavedTokens(req.compression_tokens_saved ?? 0)}</div> <div>${formatSavedTokens(req.compression_tokens_saved ?? 0)}</div>
@ -2249,6 +2250,7 @@
} }
function formatCompression(req) { 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 mode = String(req.compression_mode || 'none:none').split(':').pop() || 'none';
const pct = Number(req.compression_savings_pct || 0); const pct = Number(req.compression_savings_pct || 0);
if (!req.compression_mode) return 'not tracked'; if (!req.compression_mode) return 'not tracked';
@ -2256,6 +2258,10 @@
return `${escapeHtml(mode)} · ${pct.toFixed(1)}%`; return `${escapeHtml(mode)} · ${pct.toFixed(1)}%`;
} }
function formatRequestStatus(req) {
return String(req.request_id || '').startsWith('usage-import:') ? 'bypassed' : escapeHtml(req.status);
}
function escapeHtml(s) { function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c])); return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
} }

View File

@ -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('<EMAIL_001>');
expect(text).toContain('ctxlean omitted');
expect(transformed.tools).toEqual(payload.tools);
});
});

View File

@ -143,8 +143,7 @@ export class RequestLogger {
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT mode, tokens_before, tokens_after, savings_pct SELECT mode, tokens_before, tokens_after, savings_pct
FROM tokenvault_metrics FROM tokenvault_metrics
WHERE tool_used = 'gateway' WHERE file_path = rt.request_id
AND file_path = rt.request_id
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1 LIMIT 1
) tv ON true ) tv ON true

View File

@ -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<string, number>): 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<string, unknown>).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<string, string> {
const headers: Record<string, string> = {};
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<void> {
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<FastifyReply> {
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<string, unknown>)?.['model'] === 'string'
? String((rawPayload as Record<string, unknown>)['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,
},
});
}
}

View File

@ -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<string, string>;
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('<EMAIL_001>');
await app.close();
});
});

View File

@ -68,6 +68,11 @@ import { runPreComplete, runPostComplete } from '../modules/plugin-system.js';
import { getAdaptiveRecommendation } from '../modules/adaptive-routing.js'; import { getAdaptiveRecommendation } from '../modules/adaptive-routing.js';
import { guardOutputStream, getOutputDefenseMode } from '../modules/output-defense.js'; import { guardOutputStream, getOutputDefenseMode } from '../modules/output-defense.js';
import { callPromptGuard, isPromptGuardConfigured, getPromptGuardThreshold, getPromptGuardMinLen } from '../modules/prompt-guard-client.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) // // Disable Ollama-dependent scanners (sentinel, constitutional, embedding, attention)
// // to keep gateway scans fast and dependency-free // // to keep gateway scans fast and dependency-free
@ -121,6 +126,11 @@ const CompletionRequestSchema = z.object({
type CompletionRequest = z.infer<typeof CompletionRequestSchema>; type CompletionRequest = z.infer<typeof CompletionRequestSchema>;
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 { function shouldBypassResponseCache(caller: string): boolean {
const normalized = caller.toLowerCase(); const normalized = caller.toLowerCase();
return normalized.includes('claude-code') return normalized.includes('claude-code')
@ -1523,7 +1533,7 @@ export async function completionRoute(fastify: FastifyInstance): Promise<void> {
return reply.send(listGatewayModels()); 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 startMs = Date.now();
const parsed = OpenAIChatCompletionRequestSchema.safeParse(request.body); const parsed = OpenAIChatCompletionRequestSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
@ -1538,6 +1548,9 @@ export async function completionRoute(fastify: FastifyInstance): Promise<void> {
const callId = `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; const callId = `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const requestedSubscriptionId = requestedSubscriptionFromHeaders(request); 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 { caller: bridgeCaller } = detectCaller(request, 'openai-compatible', parsed.data.user);
const subscriptionBridgeResult = await callSubscriptionBridgeChatCompletion(parsed.data, startMs, callId, requestedSubscriptionId, bridgeCaller); const subscriptionBridgeResult = await callSubscriptionBridgeChatCompletion(parsed.data, startMs, callId, requestedSubscriptionId, bridgeCaller);
if (requestedSubscriptionId && !subscriptionBridgeResult) { if (requestedSubscriptionId && !subscriptionBridgeResult) {
@ -1581,8 +1594,21 @@ export async function completionRoute(fastify: FastifyInstance): Promise<void> {
}); });
// Anthropic Messages API compatibility — accept @anthropic-ai/sdk traffic. // 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 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); const parsed = AnthropicMessagesRequestSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ return reply.status(400).send({
@ -1594,6 +1620,10 @@ export async function completionRoute(fastify: FastifyInstance): Promise<void> {
}); });
} }
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 callId = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const gatewayRequest = anthropicRequestToGatewayRequest(parsed.data, request); const gatewayRequest = anthropicRequestToGatewayRequest(parsed.data, request);
const result = await executeCompletion(gatewayRequest, startMs, callId); const result = await executeCompletion(gatewayRequest, startMs, callId);
@ -1622,8 +1652,33 @@ export async function completionRoute(fastify: FastifyInstance): Promise<void> {
return reply.status(200).send(response); 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 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); const parsed = OpenAIResponsesRequestSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
return reply.status(400).send({ return reply.status(400).send({
@ -1643,6 +1698,7 @@ export async function completionRoute(fastify: FastifyInstance): Promise<void> {
// the right auth. Route them straight to the local codex-bridge // the right auth. Route them straight to the local codex-bridge
// (local bridge process) which speaks codex-cli over OAuth. // (local bridge process) which speaks codex-cli over OAuth.
if (isCodexBridgeModel(parsed.data.model)) { if (isCodexBridgeModel(parsed.data.model)) {
if (!await authorizeGatewayRequest(request, reply)) return reply;
try { try {
const bridgeUrl = process.env['CODEX_BRIDGE_URL'] ?? 'http://localhost:3253'; const bridgeUrl = process.env['CODEX_BRIDGE_URL'] ?? 'http://localhost:3253';
const bridgeCaller = (request.headers['x-llm-interceptor-caller'] as string) || 'codex-app'; const bridgeCaller = (request.headers['x-llm-interceptor-caller'] as string) || 'codex-app';
@ -1790,6 +1846,18 @@ data: [DONE]
return reply.send(response); 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 ──────────────────────────────────── // ─── Multi-Model Race Mode endpoint ────────────────────────────────────
// Runs the same prompt against multiple models in parallel; returns // Runs the same prompt against multiple models in parallel; returns
// according to `strategy` (first | best | consensus). Audits each // according to `strategy` (first | best | consensus). Audits each

View File

@ -1753,22 +1753,30 @@ export async function dashboardRoute(fastify: FastifyInstance): Promise<void> {
// Upsert by request_id (one row per caller/model/day): re-reporting an // Upsert by request_id (one row per caller/model/day): re-reporting an
// in-progress day updates its totals instead of creating duplicates. // in-progress day updates its totals instead of creating duplicates.
const updated = await db.query( 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] [tokensIn, tokensOut, costUsd, usedAt, requestId]
); );
if (updated.rowCount === 0) { if (updated.rowCount === 0) {
const requestLogger = createRequestLogger(db); 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 db.query(`UPDATE request_tracking SET created_at=$1 WHERE request_id=$2`, [usedAt, requestId]);
} }
await logCompressionMetric(db, { const updatedMetric = await db.query(
filePath: requestId, `UPDATE tokenvault_metrics
mode: 'usage-import:none', SET mode='usage-import:none', tokens_before=$2, tokens_after=$2, savings_pct=0, created_at=NOW(), tool_used='usage-import'
tokensBefore: tokensIn, WHERE id=(SELECT id FROM tokenvault_metrics WHERE file_path=$1 ORDER BY created_at DESC LIMIT 1)`,
tokensAfter: tokensIn, [requestId, tokensIn]
savingsPct: 0, );
toolUsed: 'gateway', 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 } }); return reply.status(200).send({ success: true, imported: { caller, model, day, tokensIn, tokensOut, costUsd, usedAt } });
} catch (error) { } catch (error) {
logger.error({ error }, 'Failed to import usage report'); logger.error({ error }, 'Failed to import usage report');

View File

@ -14,6 +14,7 @@ import {
import { getPublicSettings, saveSettings } from '../modules/settings-store.js'; import { getPublicSettings, saveSettings } from '../modules/settings-store.js';
import { getAllProviders, getAvailableProviders } from '../pipeline/external-providers.js'; import { getAllProviders, getAvailableProviders } from '../pipeline/external-providers.js';
import { logger } from '../observability/logger.js'; import { logger } from '../observability/logger.js';
import { requireGatewayApiKey } from '../security/tenant-auth.js';
interface SubscriptionBridgeBody { interface SubscriptionBridgeBody {
subscription_id?: SubscriptionId; subscription_id?: SubscriptionId;
@ -126,7 +127,7 @@ function buildAccessCard(
` -H "X-Caller-ID: pilot-client"`, ` -H "X-Caller-ID: pilot-client"`,
` -d '{"model":"${defaultModel}","messages":[{"role":"user","content":"gateway bridge smoke"}]}'`, ` -d '{"model":"${defaultModel}","messages":[{"role":"user","content":"gateway bridge smoke"}]}'`,
].join(' \\\n'), ].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<void> { export async function subscriptionsRoute(fastify: FastifyInstance): Promise<void> {
const auth = { preHandler: requireDashboardAuth }; const auth = { preHandler: requireDashboardAuth };
const apiAuth = { preHandler: requireGatewayApiKey };
fastify.get('/api/subscriptions', auth, async (request: FastifyRequest, reply: FastifyReply) => { fastify.get('/api/subscriptions', auth, async (request: FastifyRequest, reply: FastifyReply) => {
const query = request.query as { search?: string; q?: string }; const query = request.query as { search?: string; q?: string };
@ -521,7 +523,7 @@ export async function subscriptionsRoute(fastify: FastifyInstance): Promise<void
} }
}); });
fastify.post('/api/subscriptions/:subscription_id/v1/chat/completions', auth, async (request: FastifyRequest, reply: FastifyReply) => { fastify.post('/api/subscriptions/:subscription_id/v1/chat/completions', apiAuth, async (request: FastifyRequest, reply: FastifyReply) => {
const params = request.params as SubscriptionChatParams; const params = request.params as SubscriptionChatParams;
const status = await getSubscriptionStatusOrReply(params.subscription_id, reply); const status = await getSubscriptionStatusOrReply(params.subscription_id, reply);
if (!status) return reply; if (!status) return reply;
@ -542,6 +544,8 @@ export async function subscriptionsRoute(fastify: FastifyInstance): Promise<void
url: '/v1/chat/completions', url: '/v1/chat/completions',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
...(request.headers.authorization ? { authorization: request.headers.authorization } : {}),
...(request.headers['x-llm-gateway-key'] ? { 'x-llm-gateway-key': request.headers['x-llm-gateway-key'] } : {}),
'x-caller-id': Array.isArray(caller) ? caller[0] : String(caller), 'x-caller-id': Array.isArray(caller) ? caller[0] : String(caller),
'x-llm-gateway-subscription': status.descriptor.id, 'x-llm-gateway-subscription': status.descriptor.id,
}, },

View File

@ -0,0 +1,76 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { getPool } from '../db/client.js';
import { createRequestLogger } from '../modules/request-logger.js';
import { logger } from '../observability/logger.js';
import { requireGatewayApiKey } from '../security/tenant-auth.js';
import { logCompressionMetric } from '../utils/tokenvault-hooks.js';
const BridgeTrackingReportSchema = z.object({
request_id: z.string().min(8).max(220).optional(),
caller: z.string().min(1).max(120),
model: z.string().min(1).max(120),
source: z.string().min(1).max(80).default('subscription-bridge'),
tokens_in: z.number().int().nonnegative().max(100_000_000),
tokens_out: z.number().int().nonnegative().max(100_000_000),
latency_ms: z.number().int().nonnegative().max(3_600_000).default(0),
compression_tokens_before: z.number().int().nonnegative().max(100_000_000).optional(),
compression_tokens_after: z.number().int().nonnegative().max(100_000_000).optional(),
compression_mode: z.string().min(1).max(48).optional(),
});
function safeRequestId(value: string | undefined): string {
if (value && /^[A-Za-z0-9:._-]{8,220}$/.test(value)) return value;
return `bridge-report:${Date.now()}:${Math.random().toString(36).slice(2, 9)}`;
}
export async function trackingRoute(fastify: FastifyInstance): Promise<void> {
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' });
}
},
);
}

View File

@ -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<string, string>): 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);
});
});

View File

@ -1,9 +1,93 @@
import fp from 'fastify-plugin'; 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<boolean> {
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<FastifyReply | void> {
const authorized = await authorizeGatewayRequest(request, reply);
if (!authorized) return reply;
}
async function tenantAuth(_fastify: FastifyInstance): Promise<void> { async function tenantAuth(_fastify: FastifyInstance): Promise<void> {
// Tenant auth is intentionally permissive until tenant policies are configured. logger.info({ mode: getGatewayApiAuthMode(), configuredKeys: getConfiguredGatewayApiKeys().length }, 'Gateway API authentication initialised');
// Admin and dashboard routes keep their own token checks.
} }
export default fp(tenantAuth, { name: 'tenant-auth' }); export default fp(tenantAuth, { name: 'tenant-auth' });

View File

@ -3,6 +3,7 @@ import fastifyCors from '@fastify/cors';
import fastifyRateLimit from '@fastify/rate-limit'; import fastifyRateLimit from '@fastify/rate-limit';
import fastifyHelmet from '@fastify/helmet'; import fastifyHelmet from '@fastify/helmet';
import { completionRoute } from './routes/completion.js'; import { completionRoute } from './routes/completion.js';
import { trackingRoute } from './routes/tracking.js';
import { batchRoute } from './routes/batch.js'; import { batchRoute } from './routes/batch.js';
import { classifyRoute } from './routes/classify.js'; import { classifyRoute } from './routes/classify.js';
import { guardRoute } from './routes/guard.js'; import { guardRoute } from './routes/guard.js';
@ -111,7 +112,18 @@ async function buildServer() {
/^https:\/\/.*\.runwerk\.app$/, /^https:\/\/.*\.runwerk\.app$/,
], ],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], 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, credentials: true,
}); });
@ -132,6 +144,7 @@ async function buildServer() {
await server.register(tenantAuth); await server.register(tenantAuth);
await server.register(internalRoute); await server.register(internalRoute);
await server.register(trackingRoute);
await server.register(completionRoute, { prefix: '/v1' }); await server.register(completionRoute, { prefix: '/v1' });
await server.register(embeddingsRoute, { prefix: '/v1' }); await server.register(embeddingsRoute, { prefix: '/v1' });
await server.register(replayRoute, { prefix: '/v1' }); await server.register(replayRoute, { prefix: '/v1' });

View File

@ -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")

View File

@ -3,6 +3,7 @@
# Production fix for the recurring PM2 env-drop quirk. # Production fix for the recurring PM2 env-drop quirk.
set -a set -a
[ -f /opt/llm-gateway/.env.defense ] && source /opt/llm-gateway/.env.defense [ -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 [ -f /opt/llm-gateway/.env ] && source /opt/llm-gateway/.env
set +a set +a
exec /usr/bin/node /opt/llm-gateway/packages/gateway/scripts/launch.mjs exec /usr/bin/node /opt/llm-gateway/packages/gateway/scripts/launch.mjs