| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575 |
- import { beforeEach, describe, expect, test, vi } from "vitest";
- import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy";
- const mocks = vi.hoisted(() => {
- return {
- pickRandomProviderWithExclusion: vi.fn(),
- recordSuccess: vi.fn(),
- recordFailure: vi.fn(async () => {}),
- getCircuitState: vi.fn(() => "closed"),
- getProviderHealthInfo: vi.fn(async () => ({
- health: { failureCount: 0 },
- config: { failureThreshold: 3 },
- })),
- updateMessageRequestDetails: vi.fn(async () => {}),
- isHttp2Enabled: vi.fn(async () => false),
- getPreferredProviderEndpoints: vi.fn(async () => []),
- getEndpointFilterStats: vi.fn(async () => null),
- recordEndpointSuccess: vi.fn(async () => {}),
- recordEndpointFailure: vi.fn(async () => {}),
- isVendorTypeCircuitOpen: vi.fn(async () => false),
- recordVendorTypeAllEndpointsTimeout: vi.fn(async () => {}),
- // ErrorCategory.PROVIDER_ERROR
- categorizeErrorAsync: vi.fn(async () => 0),
- };
- });
- vi.mock("@/lib/logger", () => ({
- logger: {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- },
- }));
- vi.mock("@/lib/config", async (importOriginal) => {
- const actual = await importOriginal<typeof import("@/lib/config")>();
- return {
- ...actual,
- isHttp2Enabled: mocks.isHttp2Enabled,
- };
- });
- vi.mock("@/lib/provider-endpoints/endpoint-selector", () => ({
- getPreferredProviderEndpoints: mocks.getPreferredProviderEndpoints,
- getEndpointFilterStats: mocks.getEndpointFilterStats,
- }));
- vi.mock("@/lib/endpoint-circuit-breaker", () => ({
- recordEndpointSuccess: mocks.recordEndpointSuccess,
- recordEndpointFailure: mocks.recordEndpointFailure,
- }));
- vi.mock("@/lib/circuit-breaker", () => ({
- getCircuitState: mocks.getCircuitState,
- getProviderHealthInfo: mocks.getProviderHealthInfo,
- recordFailure: mocks.recordFailure,
- recordSuccess: mocks.recordSuccess,
- }));
- vi.mock("@/lib/vendor-type-circuit-breaker", () => ({
- isVendorTypeCircuitOpen: mocks.isVendorTypeCircuitOpen,
- recordVendorTypeAllEndpointsTimeout: mocks.recordVendorTypeAllEndpointsTimeout,
- }));
- vi.mock("@/repository/message", () => ({
- updateMessageRequestDetails: mocks.updateMessageRequestDetails,
- }));
- vi.mock("@/app/v1/_lib/proxy/provider-selector", () => ({
- ProxyProviderResolver: {
- pickRandomProviderWithExclusion: mocks.pickRandomProviderWithExclusion,
- },
- }));
- vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => {
- const actual = await importOriginal<typeof import("@/app/v1/_lib/proxy/errors")>();
- return {
- ...actual,
- categorizeErrorAsync: mocks.categorizeErrorAsync,
- };
- });
- import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
- import { ProxyError } from "@/app/v1/_lib/proxy/errors";
- import { ProxySession } from "@/app/v1/_lib/proxy/session";
- import type { Provider } from "@/types/provider";
- function createProvider(overrides: Partial<Provider> = {}): Provider {
- return {
- id: 1,
- name: "p1",
- url: "https://provider.example.com",
- key: "k",
- providerVendorId: null,
- isEnabled: true,
- weight: 1,
- priority: 0,
- groupPriorities: null,
- costMultiplier: 1,
- groupTag: null,
- providerType: "claude",
- preserveClientIp: false,
- modelRedirects: null,
- allowedModels: null,
- mcpPassthroughType: "none",
- mcpPassthroughUrl: null,
- limit5hUsd: null,
- limitDailyUsd: null,
- dailyResetMode: "fixed",
- dailyResetTime: "00:00",
- limitWeeklyUsd: null,
- limitMonthlyUsd: null,
- limitTotalUsd: null,
- totalCostResetAt: null,
- limitConcurrentSessions: 0,
- maxRetryAttempts: 1,
- circuitBreakerFailureThreshold: 5,
- circuitBreakerOpenDuration: 1_800_000,
- circuitBreakerHalfOpenSuccessThreshold: 2,
- proxyUrl: null,
- proxyFallbackToDirect: false,
- firstByteTimeoutStreamingMs: 30_000,
- streamingIdleTimeoutMs: 10_000,
- requestTimeoutNonStreamingMs: 1_000,
- websiteUrl: null,
- faviconUrl: null,
- cacheTtlPreference: null,
- context1mPreference: null,
- codexReasoningEffortPreference: null,
- codexReasoningSummaryPreference: null,
- codexTextVerbosityPreference: null,
- codexParallelToolCallsPreference: null,
- anthropicMaxTokensPreference: null,
- anthropicThinkingBudgetPreference: null,
- anthropicAdaptiveThinking: null,
- geminiGoogleSearchPreference: null,
- tpm: 0,
- rpm: 0,
- rpd: 0,
- cc: 0,
- createdAt: new Date(),
- updatedAt: new Date(),
- deletedAt: null,
- ...overrides,
- };
- }
- function createSession(): ProxySession {
- const headers = new Headers();
- const session = Object.create(ProxySession.prototype);
- Object.assign(session, {
- startTime: Date.now(),
- method: "POST",
- requestUrl: new URL("https://example.com/v1/messages"),
- headers,
- originalHeaders: new Headers(headers),
- headerLog: JSON.stringify(Object.fromEntries(headers.entries())),
- request: {
- model: "claude-test",
- log: "(test)",
- message: {
- model: "claude-test",
- messages: [{ role: "user", content: "hi" }],
- },
- },
- userAgent: null,
- context: null,
- clientAbortSignal: null,
- userName: "test-user",
- authState: { success: true, user: null, key: null, apiKey: null },
- provider: null,
- messageContext: null,
- sessionId: null,
- requestSequence: 1,
- originalFormat: "claude",
- providerType: null,
- originalModelName: null,
- originalUrlPathname: null,
- providerChain: [],
- cacheTtlResolved: null,
- context1mApplied: false,
- specialSettings: [],
- cachedPriceData: undefined,
- cachedBillingModelSource: undefined,
- endpointPolicy: resolveEndpointPolicy("/v1/messages"),
- isHeaderModified: () => false,
- });
- return session as ProxySession;
- }
- describe("ProxyForwarder - fake 200 HTML body", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
- test("200 + text/html 的 HTML 页面应视为失败并切换供应商", async () => {
- const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
- const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
- const session = createSession();
- session.setProvider(provider1);
- mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
- const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
- const htmlBody = [
- "<!doctype html>",
- "<html><head><title>New API</title></head>",
- "<body>blocked</body></html>",
- ].join("\n");
- const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
- doForward.mockResolvedValueOnce(
- new Response(htmlBody, {
- status: 200,
- headers: {
- "content-type": "text/html; charset=utf-8",
- "content-length": String(htmlBody.length),
- },
- })
- );
- doForward.mockResolvedValueOnce(
- new Response(okJson, {
- status: 200,
- headers: {
- "content-type": "application/json; charset=utf-8",
- "content-length": String(okJson.length),
- },
- })
- );
- const response = await ProxyForwarder.send(session);
- expect(await response.text()).toContain("ok");
- expect(doForward).toHaveBeenCalledTimes(2);
- expect(doForward.mock.calls[0][1].id).toBe(1);
- expect(doForward.mock.calls[1][1].id).toBe(2);
- expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
- expect(mocks.recordFailure).toHaveBeenCalledWith(
- 1,
- expect.objectContaining({ message: "FAKE_200_HTML_BODY" })
- );
- const failure1 = mocks.recordFailure.mock.calls[0]?.[1];
- expect(failure1).toBeInstanceOf(ProxyError);
- expect((failure1 as ProxyError).getClientSafeMessage()).toContain("HTML document");
- expect((failure1 as ProxyError).getClientSafeMessage()).toContain("Upstream detail:");
- expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
- expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
- });
- test("200 + text/html 但 body 是 JSON error 也应视为失败并切换供应商", async () => {
- const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
- const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
- const session = createSession();
- session.setProvider(provider1);
- mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
- const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
- const jsonErrorBody = JSON.stringify({ error: "upstream blocked" });
- const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
- doForward.mockResolvedValueOnce(
- new Response(jsonErrorBody, {
- status: 200,
- headers: {
- // 故意使用 text/html:模拟部分上游 Content-Type 错配但 body 仍为错误 JSON 的情况
- "content-type": "text/html; charset=utf-8",
- "content-length": String(jsonErrorBody.length),
- },
- })
- );
- doForward.mockResolvedValueOnce(
- new Response(okJson, {
- status: 200,
- headers: {
- "content-type": "application/json; charset=utf-8",
- "content-length": String(okJson.length),
- },
- })
- );
- const response = await ProxyForwarder.send(session);
- expect(await response.text()).toContain("ok");
- expect(doForward).toHaveBeenCalledTimes(2);
- expect(doForward.mock.calls[0][1].id).toBe(1);
- expect(doForward.mock.calls[1][1].id).toBe(2);
- expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
- expect(mocks.recordFailure).toHaveBeenCalledWith(
- 1,
- expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" })
- );
- const failure2 = mocks.recordFailure.mock.calls[0]?.[1];
- expect(failure2).toBeInstanceOf(ProxyError);
- expect((failure2 as ProxyError).getClientSafeMessage()).toContain("JSON body");
- expect((failure2 as ProxyError).getClientSafeMessage()).toContain("`error`");
- expect((failure2 as ProxyError).getClientSafeMessage()).toContain("upstream blocked");
- expect((failure2 as ProxyError).upstreamError?.rawBody).toBe(jsonErrorBody);
- expect((failure2 as ProxyError).upstreamError?.rawBodyTruncated).toBe(false);
- expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
- expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
- });
- test("200 + application/json 且有 Content-Length 的 JSON error 也应视为失败并切换供应商", async () => {
- const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
- const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
- const session = createSession();
- session.setProvider(provider1);
- mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
- const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
- const jsonErrorBody = JSON.stringify({ error: "upstream blocked" });
- const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
- doForward.mockResolvedValueOnce(
- new Response(jsonErrorBody, {
- status: 200,
- headers: {
- "content-type": "application/json; charset=utf-8",
- "content-length": String(jsonErrorBody.length),
- },
- })
- );
- doForward.mockResolvedValueOnce(
- new Response(okJson, {
- status: 200,
- headers: {
- "content-type": "application/json; charset=utf-8",
- "content-length": String(okJson.length),
- },
- })
- );
- const response = await ProxyForwarder.send(session);
- expect(await response.text()).toContain("ok");
- expect(doForward).toHaveBeenCalledTimes(2);
- expect(doForward.mock.calls[0][1].id).toBe(1);
- expect(doForward.mock.calls[1][1].id).toBe(2);
- expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
- expect(mocks.recordFailure).toHaveBeenCalledWith(
- 1,
- expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" })
- );
- const failure3 = mocks.recordFailure.mock.calls[0]?.[1];
- expect(failure3).toBeInstanceOf(ProxyError);
- expect((failure3 as ProxyError).getClientSafeMessage()).toContain("JSON body");
- expect((failure3 as ProxyError).getClientSafeMessage()).toContain("`error`");
- expect((failure3 as ProxyError).getClientSafeMessage()).toContain("upstream blocked");
- expect((failure3 as ProxyError).upstreamError?.rawBody).toBe(jsonErrorBody);
- expect((failure3 as ProxyError).upstreamError?.rawBodyTruncated).toBe(false);
- expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
- expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
- });
- test("假200 JSON error 命中 rate limit 关键字时,应推断为 429 并在决策链中标记为推断", async () => {
- const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
- const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
- const session = createSession();
- session.setProvider(provider1);
- mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
- const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
- const jsonErrorBody = JSON.stringify({ error: "Rate limit exceeded" });
- const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
- doForward.mockResolvedValueOnce(
- new Response(jsonErrorBody, {
- status: 200,
- headers: {
- "content-type": "application/json; charset=utf-8",
- "content-length": String(jsonErrorBody.length),
- },
- })
- );
- doForward.mockResolvedValueOnce(
- new Response(okJson, {
- status: 200,
- headers: {
- "content-type": "application/json; charset=utf-8",
- "content-length": String(okJson.length),
- },
- })
- );
- const response = await ProxyForwarder.send(session);
- expect(await response.text()).toContain("ok");
- expect(mocks.recordFailure).toHaveBeenCalledWith(
- 1,
- expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" })
- );
- const failure = mocks.recordFailure.mock.calls[0]?.[1];
- expect(failure).toBeInstanceOf(ProxyError);
- expect((failure as ProxyError).statusCode).toBe(429);
- expect((failure as ProxyError).upstreamError?.statusCodeInferred).toBe(true);
- const chain = session.getProviderChain();
- expect(
- chain.some(
- (item) =>
- item.id === 1 &&
- item.reason === "retry_failed" &&
- item.statusCode === 429 &&
- item.statusCodeInferred === true
- )
- ).toBe(true);
- });
- test("200 + 非法 Content-Length 时应按缺失处理,避免漏检 HTML 假200", async () => {
- const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
- const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
- const session = createSession();
- session.setProvider(provider1);
- mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
- const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
- const htmlErrorBody = "<!doctype html><html><body>blocked</body></html>";
- const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
- doForward.mockResolvedValueOnce(
- new Response(htmlErrorBody, {
- status: 200,
- headers: {
- // 故意不提供 html/json 的 Content-Type,覆盖“仅靠 body 嗅探”的假200检测分支
- "content-type": "text/plain; charset=utf-8",
- // 非法 Content-Length:parseInt("12abc") 会返回 12;修复后应视为非法并进入 body 检查分支
- "content-length": "12abc",
- },
- })
- );
- doForward.mockResolvedValueOnce(
- new Response(okJson, {
- status: 200,
- headers: {
- "content-type": "application/json; charset=utf-8",
- "content-length": String(okJson.length),
- },
- })
- );
- const response = await ProxyForwarder.send(session);
- expect(await response.text()).toContain("ok");
- expect(doForward).toHaveBeenCalledTimes(2);
- expect(doForward.mock.calls[0][1].id).toBe(1);
- expect(doForward.mock.calls[1][1].id).toBe(2);
- expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
- expect(mocks.recordFailure).toHaveBeenCalledWith(
- 1,
- expect.objectContaining({ message: "FAKE_200_HTML_BODY" })
- );
- const failure = mocks.recordFailure.mock.calls[0]?.[1];
- expect(failure).toBeInstanceOf(ProxyError);
- expect((failure as ProxyError).upstreamError?.rawBody).toBe(htmlErrorBody);
- expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
- expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
- });
- test("缺少 content 字段(missing_content)不应被 JSON 解析 catch 吞掉,应触发切换供应商", async () => {
- const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
- const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
- const session = createSession();
- session.setProvider(provider1);
- mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
- const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
- const missingContentJson = JSON.stringify({ type: "message", content: [] });
- const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
- doForward.mockResolvedValueOnce(
- new Response(missingContentJson, {
- status: 200,
- headers: {
- "content-type": "application/json; charset=utf-8",
- // 故意不提供 content-length:覆盖 forwarder 的 clone + JSON 内容结构检查分支
- },
- })
- );
- doForward.mockResolvedValueOnce(
- new Response(okJson, {
- status: 200,
- headers: {
- "content-type": "application/json; charset=utf-8",
- "content-length": String(okJson.length),
- },
- })
- );
- const response = await ProxyForwarder.send(session);
- expect(await response.text()).toContain("ok");
- expect(doForward).toHaveBeenCalledTimes(2);
- expect(doForward.mock.calls[0][1].id).toBe(1);
- expect(doForward.mock.calls[1][1].id).toBe(2);
- expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
- expect(mocks.recordFailure).toHaveBeenCalledWith(
- 1,
- expect.objectContaining({ reason: "missing_content" })
- );
- expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
- expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
- });
- });
- describe("ProxyError.getClientSafeMessage - FAKE_200 sanitization", () => {
- test("upstream body 包含 JWT 和 email 时应被脱敏为 [JWT] / [EMAIL]", () => {
- const jwtToken =
- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U";
- const email = "[email protected]";
- const body = `Authentication failed for ${email} with token ${jwtToken}`;
- const error = new ProxyError("FAKE_200_JSON_ERROR_NON_EMPTY", 502, {
- body,
- providerId: 1,
- providerName: "p1",
- });
- const msg = error.getClientSafeMessage();
- expect(msg).toContain("[JWT]");
- expect(msg).toContain("[EMAIL]");
- expect(msg).not.toContain(jwtToken);
- expect(msg).not.toContain(email);
- expect(msg).toContain("Upstream detail:");
- });
- test("upstream body 包含 password=xxx 时应被脱敏", () => {
- const body = "Config error: password=s3cretValue in /etc/app.json";
- const error = new ProxyError("FAKE_200_HTML_BODY", 502, {
- body,
- providerId: 1,
- providerName: "p1",
- });
- const msg = error.getClientSafeMessage();
- expect(msg).not.toContain("s3cretValue");
- expect(msg).toContain("[PATH]");
- expect(msg).toContain("Upstream detail:");
- });
- });
|