mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-10 17:22:17 +03:00
Compare commits
1 Commits
feat/9620-
...
fix/9981-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ea614ab12 |
@@ -1,2 +0,0 @@
|
||||
- Show cache-read and cache-write token counts in request log rows and details when providers
|
||||
report them.
|
||||
1
changelog.d/fixes/9981-image-error-normalization.md
Normal file
1
changelog.d/fixes/9981-image-error-normalization.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(images): normalize terminal upstream errors via OpenAI-standard type/code (#9981)
|
||||
@@ -308,10 +308,11 @@ async function postHandler(request, context) {
|
||||
}
|
||||
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: (result as any).status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const message =
|
||||
typeof errorPayload?.error?.message === "string"
|
||||
? errorPayload.error.message
|
||||
: "Image generation provider error";
|
||||
return errorResponse((result as any).status, message);
|
||||
}
|
||||
|
||||
export const POST = withInjectionGuard(postHandler);
|
||||
|
||||
@@ -119,8 +119,9 @@ export async function POST(request, { params }) {
|
||||
}
|
||||
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: (result as any).status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const message =
|
||||
typeof errorPayload?.error?.message === "string"
|
||||
? errorPayload.error.message
|
||||
: "Image generation provider error";
|
||||
return errorResponse((result as any).status, message);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,6 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
(props, ref) => {
|
||||
const { initialSelectedId } = props as any;
|
||||
const t = useTranslations("requestLogger");
|
||||
const tCache = useTranslations("cache");
|
||||
const { emailsVisible } = useEmailPrivacyStore();
|
||||
|
||||
// Get translated status filters
|
||||
@@ -1515,30 +1514,6 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
|
||||
<span className="text-emerald-700 dark:text-emerald-400">
|
||||
{log.tokens?.out?.toLocaleString() || 0}
|
||||
</span>
|
||||
{log.tokens?.cacheRead != null && log.tokens.cacheRead > 0 && (
|
||||
<>
|
||||
<span className="mx-1 text-border">|</span>
|
||||
<span className="text-text-muted">CR:</span>{" "}
|
||||
<span
|
||||
className="text-sky-700 dark:text-sky-400"
|
||||
title={tCache("cachedTokensCol")}
|
||||
>
|
||||
{log.tokens.cacheRead.toLocaleString()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{log.tokens?.cacheWrite != null && log.tokens.cacheWrite > 0 && (
|
||||
<>
|
||||
<span className="mx-1 text-border">|</span>
|
||||
<span className="text-text-muted">CW:</span>{" "}
|
||||
<span
|
||||
className="text-amber-700 dark:text-amber-400"
|
||||
title={tCache("cacheCreation")}
|
||||
>
|
||||
{log.tokens.cacheWrite.toLocaleString()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{log.tokens?.compressed != null && log.tokens.compressed > 0 && (
|
||||
<>
|
||||
<span className="mx-1 text-border">|</span>
|
||||
|
||||
@@ -701,6 +701,67 @@ test("provider-scoped image generation POST uses the shared 401 account fallback
|
||||
]);
|
||||
});
|
||||
|
||||
test("v1 image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => {
|
||||
await seedConnection("openai", { apiKey: "single-expired-image-key" });
|
||||
|
||||
globalThis.fetch = async (url, options: RequestInit = {}) => {
|
||||
assert.equal(String(url), "https://api.openai.com/v1/images/generations");
|
||||
const authorization = new Headers(options.headers).get("authorization") ?? "";
|
||||
assert.equal(authorization, "Bearer single-expired-image-key");
|
||||
return new Response(JSON.stringify({ error: { message: "expired access token" } }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const response = await imageRoute.POST(
|
||||
new Request("http://localhost/api/v1/images/generations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: "openai/gpt-image-2", prompt: "normalize terminal 401" }),
|
||||
})
|
||||
);
|
||||
const body = (await response.json()) as ErrorResponseBody;
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(body.error, {
|
||||
message: "expired access token",
|
||||
type: "authentication_error",
|
||||
code: "invalid_api_key",
|
||||
});
|
||||
});
|
||||
|
||||
test("provider-scoped image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => {
|
||||
await seedConnection("openai", { apiKey: "provider-single-expired-key" });
|
||||
|
||||
globalThis.fetch = async (url, options: RequestInit = {}) => {
|
||||
assert.equal(String(url), "https://api.openai.com/v1/images/generations");
|
||||
const authorization = new Headers(options.headers).get("authorization") ?? "";
|
||||
assert.equal(authorization, "Bearer provider-single-expired-key");
|
||||
return new Response(JSON.stringify({ error: { message: "expired provider token" } }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const response = await providerImageRoute.POST(
|
||||
new Request("http://localhost/api/v1/providers/openai/images/generations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: "gpt-image-2", prompt: "normalize provider terminal 401" }),
|
||||
}),
|
||||
{ params: Promise.resolve({ provider: "openai" }) }
|
||||
);
|
||||
const body = (await response.json()) as ErrorResponseBody;
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(body.error, {
|
||||
message: "expired provider token",
|
||||
type: "authentication_error",
|
||||
code: "invalid_api_key",
|
||||
});
|
||||
});
|
||||
|
||||
test("v1 image generation POST refreshes an expired Antigravity token before dispatch", async () => {
|
||||
await seedConnection("antigravity", {
|
||||
authType: "oauth",
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: (namespace?: string) => (key: string) =>
|
||||
namespace === "cache"
|
||||
? ({ cachedTokensCol: "Cache Read", cacheCreation: "Cache Write" }[key] ?? key)
|
||||
: key,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/store/emailPrivacyStore", () => ({
|
||||
default: () => ({ emailsVisible: true }),
|
||||
}));
|
||||
|
||||
const RequestLoggerV2 = (await import("@/shared/components/RequestLoggerV2")).default;
|
||||
const RequestLoggerDetail = (await import("@/shared/components/RequestLoggerDetail")).default;
|
||||
|
||||
let container: HTMLElement;
|
||||
let root: Root;
|
||||
|
||||
const populatedLog = {
|
||||
id: "log-cache",
|
||||
status: 200,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
model: "gpt-cache",
|
||||
provider: "openai",
|
||||
timestamp: "2026-08-10T12:00:00.000Z",
|
||||
duration: 1_000,
|
||||
tokens: {
|
||||
in: 1_000,
|
||||
out: 250,
|
||||
cacheRead: 800,
|
||||
cacheWrite: 120,
|
||||
reasoning: 50,
|
||||
compressed: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
async function render(component: React.ReactNode) {
|
||||
await act(async () => {
|
||||
root.render(component);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("request log cache token metrics (#9620)", () => {
|
||||
it("renders cache read/write beside the existing row token metrics", async () => {
|
||||
const emptyCacheLog = {
|
||||
...populatedLog,
|
||||
id: "log-no-cache",
|
||||
model: "gpt-no-cache",
|
||||
timestamp: "2026-08-10T11:59:00.000Z",
|
||||
tokens: { ...populatedLog.tokens, cacheRead: null, cacheWrite: 0 },
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith("/api/usage/call-logs")) {
|
||||
return Response.json([populatedLog, emptyCacheLog]);
|
||||
}
|
||||
if (url.startsWith("/api/provider-nodes")) return Response.json({ nodes: [] });
|
||||
if (url.startsWith("/api/logs/detail")) return Response.json({ enabled: false });
|
||||
return Response.json({});
|
||||
})
|
||||
);
|
||||
|
||||
await render(<RequestLoggerV2 />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const row = Array.from(container.querySelectorAll("tbody tr")).find((candidate) =>
|
||||
candidate.textContent?.includes("gpt-cache")
|
||||
);
|
||||
expect(row?.textContent).toContain("TI: 1,000");
|
||||
expect(row?.textContent).toContain("TO: 250");
|
||||
expect(row?.textContent).toContain("CR: 800");
|
||||
expect(row?.textContent).toContain("CW: 120");
|
||||
expect(row?.textContent).toContain("↓20");
|
||||
|
||||
const emptyRow = Array.from(container.querySelectorAll("tbody tr")).find((candidate) =>
|
||||
candidate.textContent?.includes("gpt-no-cache")
|
||||
);
|
||||
expect(emptyRow?.textContent).toContain("TI: 1,000");
|
||||
expect(emptyRow?.textContent).toContain("TO: 250");
|
||||
expect(emptyRow?.textContent).not.toContain("CR:");
|
||||
expect(emptyRow?.textContent).not.toContain("CW:");
|
||||
});
|
||||
|
||||
it("distinguishes cache read from cache write in the detail view", async () => {
|
||||
await render(
|
||||
<RequestLoggerDetail
|
||||
log={populatedLog}
|
||||
detail={populatedLog}
|
||||
loading={false}
|
||||
debugEnabled={false}
|
||||
onClose={noop}
|
||||
onCopy={async () => true}
|
||||
/>
|
||||
);
|
||||
|
||||
const inputGroup = container.querySelector('[data-testid="token-group-input"]');
|
||||
const outputGroup = container.querySelector('[data-testid="token-group-output"]');
|
||||
expect(inputGroup?.textContent).toContain("Total In: 1,000");
|
||||
expect(inputGroup?.textContent).toContain("Cache Read: 800");
|
||||
expect(inputGroup?.textContent).toContain("Cache Write: 120");
|
||||
expect(inputGroup?.textContent).toContain("Compressed:");
|
||||
expect(outputGroup?.textContent).toContain("Total Out: 250");
|
||||
expect(outputGroup?.textContent).toContain("Reasoning: 50");
|
||||
});
|
||||
|
||||
it("handles historical null and zero cache values without inventing usage", async () => {
|
||||
const emptyCacheLog = {
|
||||
...populatedLog,
|
||||
id: "log-no-cache",
|
||||
tokens: { ...populatedLog.tokens, cacheRead: null, cacheWrite: 0 },
|
||||
};
|
||||
|
||||
await render(
|
||||
<RequestLoggerDetail
|
||||
log={emptyCacheLog}
|
||||
detail={emptyCacheLog}
|
||||
loading={false}
|
||||
debugEnabled={false}
|
||||
onClose={noop}
|
||||
onCopy={async () => true}
|
||||
/>
|
||||
);
|
||||
|
||||
const inputGroup = container.querySelector('[data-testid="token-group-input"]');
|
||||
expect(inputGroup?.textContent).toContain("Cache Read: N/A");
|
||||
expect(inputGroup?.textContent).toContain("Cache Write: 0");
|
||||
expect(inputGroup?.textContent).toContain("Total In: 1,000");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user