Files
OmniRoute/tests/integration/proxy-context-passthrough.test.ts
diegosouzapw fd489c9594 fix(sse): remove cross-provider credential leak in emergency fallback + combo/proxy audit fixes
Deep combo-flow audit (all combo types x 14 strategies x resilience x proxy),
TDD-validated. Three production fixes:

1. chatCore executor-level emergency budget hop REMOVED: it executed the
   emergency provider's request with the FAILING provider's credentials
   (e.g. the OpenAI API key sent to integrate.api.nvidia.com) on every
   non-stream 402/quota-429 - a cross-provider credential leak that also
   never succeeded upstream (foreign keys are always rejected). Emergency
   fallback is now orchestrated exclusively by the routing layer
   (src/sse/handlers/chat.ts), which resolves credentials FOR the
   emergency provider via account selection.

2. chat.ts emergency hop gated with !comboName: combos are the operator's
   fallback policy (target-level orchestration + global fallback #689),
   and the per-target hop burned extra upstream calls against exhausted
   providers (#1731) and bypassed context-relay handoff injection on
   account switches (the switch happened inside the detour with
   comboStrategy=null, silently swapping the model for gpt-oss-120b).

3. /v1/messages/count_tokens now resolves the connection proxy and wraps
   the provider-side count in runWithProxyContext - it previously ran
   with no proxy context at all, going DIRECT regardless of configured
   proxies. safeResolveProxy failures are now logged at warn (silent
   proxy bypass was debug-only).

Tests: restores the five #1731 fast-skip integration tests skipped since
v3.8.2 (rewritten against the current routing policy, 8/8), adds
combo-strategy-fallbacks.test.ts (11 tests: fill-first/p2c/random/
cost-optimized/strict-random fallback paths, HALF_OPEN recovery, strategy
normalization), adds proxy-context-passthrough.test.ts (per-target combo
proxy independence + count_tokens proxy), and realigns the three tests
that asserted the removed leaking behavior.
2026-06-12 01:50:54 -03:00

192 lines
6.6 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import net from "node:net";
import { createChatPipelineHarness } from "./_chatPipelineHarness.ts";
// Proxy-context passthrough on execution paths:
// 1. combo targets must each run under their OWN connection's proxy
// 2. /v1/messages/count_tokens must apply the connection proxy to the
// provider-side count call (it used to run with no proxy context at all)
const harness = await createChatPipelineHarness("proxy-context-passthrough");
const { buildClaudeResponse, buildRequest, combosDb, handleChat, resetStorage, seedConnection, settingsDb, toPlainHeaders } =
harness;
const proxiesDb = await import("../../src/lib/db/proxies.ts");
const { resolveProxyForRequest } = await import("../../open-sse/utils/proxyFetch.ts");
const countTokensRoute = await import("../../src/app/api/v1/messages/count_tokens/route.ts");
type TcpStub = { port: number; close: () => Promise<void> };
// T14 fast-fail does a real TCP reachability check on the proxy host before
// entering the proxy context — back each fake proxy with a real listener.
async function startTcpStub(): Promise<TcpStub> {
const server = net.createServer((socket) => socket.destroy());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("no tcp stub address");
return {
port: address.port,
close: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
};
}
function activeProxyUrl(): string | null {
const resolved = resolveProxyForRequest("https://upstream.example/v1");
return resolved?.proxyUrl ?? null;
}
test.beforeEach(async () => {
await resetStorage();
});
test.afterEach(async () => {
await resetStorage();
});
test.after(async () => {
await harness.cleanup();
});
test("combo targets each execute under their own connection's proxy", async () => {
const stubA = await startTcpStub();
const stubB = await startTcpStub();
try {
const openaiConn = (await seedConnection("openai", {
apiKey: "sk-openai-proxy-a",
})) as any;
const claudeConn = (await seedConnection("claude", {
apiKey: "sk-claude-proxy-b",
})) as any;
await settingsDb.updateSettings({ requestRetry: 0, maxRetryIntervalSec: 0 });
await proxiesDb.createProxyAndAssign(
{ name: "proxy-a", type: "http", host: "127.0.0.1", port: stubA.port },
{ scope: "account", scopeId: openaiConn.id }
);
await proxiesDb.createProxyAndAssign(
{ name: "proxy-b", type: "http", host: "127.0.0.1", port: stubB.port },
{ scope: "account", scopeId: claudeConn.id }
);
await combosDb.createCombo({
name: "proxy-per-target-combo",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022"],
});
const proxySeen: Record<string, string | null> = {};
globalThis.fetch = async (_url: any, init: any = {}) => {
const headers = toPlainHeaders(init.headers);
const authHeader = headers.authorization ?? headers.Authorization;
const apiKeyHeader = headers["x-api-key"] ?? headers["X-Api-Key"];
if (authHeader === "Bearer sk-openai-proxy-a") {
proxySeen.openai = activeProxyUrl();
return new Response(JSON.stringify({ error: { message: "upstream unavailable" } }), {
status: 503,
headers: { "Content-Type": "application/json" },
});
}
if (apiKeyHeader === "sk-claude-proxy-b" || authHeader === "Bearer sk-claude-proxy-b") {
proxySeen.claude = activeProxyUrl();
return buildClaudeResponse("served through proxy-b");
}
throw new Error(`unexpected upstream headers: ${JSON.stringify(headers)}`);
};
const response = await handleChat(
buildRequest({
body: {
model: "proxy-per-target-combo",
stream: false,
messages: [{ role: "user", content: "proxy per target" }],
},
})
);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(body.choices[0].message.content, "served through proxy-b");
assert.ok(proxySeen.openai, "openai target must run inside a proxy context");
assert.ok(
proxySeen.openai!.includes(`127.0.0.1:${stubA.port}`),
`openai target must use proxy-a (saw ${proxySeen.openai})`
);
assert.ok(proxySeen.claude, "claude target must run inside a proxy context");
assert.ok(
proxySeen.claude!.includes(`127.0.0.1:${stubB.port}`),
`claude fallback target must use proxy-b (saw ${proxySeen.claude})`
);
} finally {
await stubA.close();
await stubB.close();
}
});
test("count_tokens provider call runs inside the connection's proxy context", async () => {
const stub = await startTcpStub();
try {
const claudeConn = (await seedConnection("claude", {
apiKey: "sk-claude-count-tokens",
})) as any;
await settingsDb.updateSettings({ requestRetry: 0, maxRetryIntervalSec: 0 });
await proxiesDb.createProxyAndAssign(
{ name: "proxy-count", type: "http", host: "127.0.0.1", port: stub.port },
{ scope: "account", scopeId: claudeConn.id }
);
let providerCallProxy: string | null | undefined;
globalThis.fetch = async (url: any, _init: any = {}) => {
const target = typeof url === "string" ? url : String(url);
if (target.includes("count_tokens")) {
providerCallProxy = activeProxyUrl();
return new Response(JSON.stringify({ input_tokens: 42 }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
throw new Error(`unexpected upstream url: ${target}`);
};
const response = await countTokensRoute.POST(
new Request("http://localhost/v1/messages/count_tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "claude/claude-3-5-sonnet-20241022",
messages: [{ role: "user", content: "count me" }],
}),
})
);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(body.source, "provider", "provider-side count should be used");
assert.equal(body.input_tokens, 42);
assert.ok(
providerCallProxy,
"count_tokens provider call must run inside the connection's proxy context"
);
assert.ok(
providerCallProxy!.includes(`127.0.0.1:${stub.port}`),
`count_tokens must use the account proxy (saw ${providerCallProxy})`
);
} finally {
await stub.close();
}
});