Skip to content
Merged
23 changes: 23 additions & 0 deletions apps/gateway/src/lib/stealth-provider-errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
buildUpstreamErrorClientPayload,
canonicalStatusText,
clientFacingUpstreamErrorMessage,
clientFacingUpstreamFailureMessage,
redactErrorDetails,
redactedProviderErrorText,
Expand Down Expand Up @@ -118,6 +119,28 @@ describe("clientFacingUpstreamFailureMessage", () => {
});
});

describe("clientFacingUpstreamErrorMessage", () => {
it("redacts an HTTP error body for stealth providers", () => {
expect(
clientFacingUpstreamErrorMessage(
"avalanche",
500,
"quota exceeded at https://plataforma-secreta.example.com",
),
).toBe(redactedProviderErrorText(500));
});

it("keeps an HTTP error body for non-stealth providers", () => {
expect(
clientFacingUpstreamErrorMessage(
"openai",
500,
"quota exceeded at https://api.openai.com/v1",
),
).toBe("quota exceeded at https://api.openai.com/v1");
});
});

describe("canonicalStatusText", () => {
it("maps known status codes and falls back to empty string", () => {
expect(canonicalStatusText(404)).toBe("Not Found");
Expand Down
14 changes: 14 additions & 0 deletions apps/gateway/src/lib/stealth-provider-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ export function clientFacingUpstreamFailureMessage(
: `${prefix}: ${errorMessage}`;
}

/**
* Client-facing message for an upstream HTTP error. Non-stealth providers
* retain the upstream message; stealth providers expose only the status code.
*/
export function clientFacingUpstreamErrorMessage(
provider: string,
statusCode: number,
rawMessage: string,
): string {
return shouldRedactProviderError(provider)
? redactedProviderErrorText(statusCode)
: rawMessage;
}

/**
* Client-facing `message`/`responseText` pair for an upstream HTTP error.
* Non-stealth providers pass the raw upstream body through unchanged.
Expand Down
29 changes: 29 additions & 0 deletions apps/gateway/src/stealth-error-redaction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ describe("stealth provider error redaction (routes)", () => {
"LLM_GLACIER_BASE_URL",
"LLM_OPENAI_API_KEY",
"LLM_OPENAI_BASE_URL",
"LLM_AVALANCHE_API_KEY",
"LLM_AVALANCHE_BASE_URL",
]) {
savedEnv[key] = process.env[key];
}
Expand All @@ -134,6 +136,8 @@ describe("stealth provider error redaction (routes)", () => {
// openai is the non-stealth control: same leaky mock, no redaction.
process.env.LLM_OPENAI_API_KEY = "openai-env-key";
process.env.LLM_OPENAI_BASE_URL = leakyServerUrl;
process.env.LLM_AVALANCHE_API_KEY = "avalanche-env-key";
process.env.LLM_AVALANCHE_BASE_URL = leakyServerUrl;
});

afterAll(async () => {
Expand Down Expand Up @@ -190,6 +194,31 @@ describe("stealth provider error redaction (routes)", () => {
return log;
}

test("/v1/videos hides the raw upstream error for a stealth provider", async () => {
await setupCreditsApiKey("stealth-video-token");
const res = await app.request("/v1/videos", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer stealth-video-token",
"x-no-fallback": "true",
},
body: JSON.stringify({
model: "avalanche/veo-3.1-generate-preview",
prompt: "A mountain range at sunrise",
size: "1920x1080",
seconds: 8,
}),
});

expect(res.status).toBe(500);
const text = await res.text();
expectNoLeak(text);
expect(JSON.parse(text).error.message).toBe(
"Upstream provider error (500 Internal Server Error)",
);
});

test("/v1/chat/completions non-streaming hides the raw upstream error", async () => {
await setupCreditsApiKey("stealth-token-nonstream");

Expand Down
5 changes: 4 additions & 1 deletion apps/gateway/src/test-utils/mock-openai-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2816,7 +2816,10 @@ mockOpenAIServer.get("/api/v1/veo/record-info", async (c) => {

return c.json({
code: 200,
msg: "success",
msg:
job.status === "failed"
? (job.error?.message ?? "Mock video generation failed")
: "success",
data: {
taskId,
successFlag,
Expand Down
94 changes: 94 additions & 0 deletions apps/gateway/src/videos/videos.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@ describe("videos", () => {
const harness = createGatewayApiTestHarness();
let mockServerUrl: string;
let originalGoogleVertexBaseUrl: string | undefined;
let originalAvalancheApiKey: string | undefined;
let originalAvalancheBaseUrl: string | undefined;

beforeAll(() => {
mockServerUrl = harness.mockServerUrl;
originalGoogleVertexBaseUrl = process.env.LLM_GOOGLE_VERTEX_BASE_URL;
originalAvalancheApiKey = process.env.LLM_AVALANCHE_API_KEY;
originalAvalancheBaseUrl = process.env.LLM_AVALANCHE_BASE_URL;
process.env.LLM_GOOGLE_VERTEX_BASE_URL = mockServerUrl;
process.env.LLM_AVALANCHE_API_KEY = "avalanche-env-key";
process.env.LLM_AVALANCHE_BASE_URL = mockServerUrl;
});

afterAll(() => {
Expand All @@ -27,6 +33,16 @@ describe("videos", () => {
} else {
delete process.env.LLM_GOOGLE_VERTEX_BASE_URL;
}
if (originalAvalancheApiKey !== undefined) {
process.env.LLM_AVALANCHE_API_KEY = originalAvalancheApiKey;
} else {
delete process.env.LLM_AVALANCHE_API_KEY;
}
if (originalAvalancheBaseUrl !== undefined) {
process.env.LLM_AVALANCHE_BASE_URL = originalAvalancheBaseUrl;
} else {
delete process.env.LLM_AVALANCHE_BASE_URL;
}
});

async function setRoutingMetrics(
Expand Down Expand Up @@ -85,6 +101,84 @@ describe("videos", () => {
expect(JSON.stringify(json)).toContain("fixed 8s clips");
});

test("/v1/videos redacts stealth-provider errors persisted by the worker", async () => {
const secret =
"SecretVendor SensitiveContentDetected at https://api.secretvendor.com";
await db.insert(tables.apiKey).values({
id: "token-id",
token: "real-token",
projectId: "project-id",
description: "Test API Key",
createdBy: "user-id",
});
await db.insert(tables.providerKey).values({
id: "provider-key-id",
token: "sk-avalanche-key",
provider: "avalanche",
organizationId: "org-id",
baseUrl: mockServerUrl,
});

const createRes = await app.request("/v1/videos", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer real-token",
},
body: JSON.stringify({
model: "avalanche/veo-3.1-generate-preview",
prompt: "A mountain range at sunrise",
size: "1920x1080",
seconds: 8,
}),
});
expect(createRes.status).toBe(200);
const created = await createRes.json();
const job = await db.query.videoJob.findFirst({
where: { id: { eq: created.id } },
});
expect(job).toBeTruthy();
setMockVideoStatus(job!.upstreamId, "failed", {
error: { message: secret },
});

await processPendingVideoJobs();

const persistedJob = await db.query.videoJob.findFirst({
where: { id: { eq: created.id } },
});
const log = await db.query.log.findFirst({
where: { requestId: { eq: job!.requestId } },
});
expect(persistedJob).toBeTruthy();
expect(log).toBeTruthy();
expect(JSON.stringify(persistedJob!.upstreamStatusResponse)).toContain(
secret,
);
expect(log!.finishReason).toBe("content_filter");
expect(log!.upstreamResponse).toBeNull();
expect(log!.internalErrorDetails).toMatchObject({ responseText: secret });
expect(log!.errorDetails).toEqual({
statusCode: 502,
statusText: "Bad Gateway",
responseText: "Upstream provider error (502 Bad Gateway)",
});
const { internalErrorDetails: _internalErrorDetails, ...publicLog } = log!;
expect(JSON.stringify(publicLog)).not.toContain("SecretVendor");
expect(JSON.stringify(publicLog)).not.toContain("secretvendor.com");

const statusRes = await app.request(`/v1/videos/${created.id}`, {
headers: { Authorization: "Bearer real-token" },
});
expect(statusRes.status).toBe(200);
const responseText = await statusRes.text();
expect(responseText).not.toContain("SecretVendor");
expect(responseText).not.toContain("secretvendor.com");
expect(JSON.parse(responseText).error).toEqual({
message: "Upstream provider error (502 Bad Gateway)",
});
});

test("/v1/videos rejects dev-plan personal orgs with 403", async () => {
await db.insert(tables.apiKey).values({
id: "token-id",
Expand Down
Loading
Loading