mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-04 22:15:07 +03:00
Compare commits
2 Commits
feat/video
...
fix/video-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a1233809 | ||
|
|
008da6d19a |
@@ -273,6 +273,21 @@ export function ConversationContextSection({ log, detail }) {
|
||||
continues from parent
|
||||
</a>
|
||||
)}
|
||||
{liveDetail?.sessionTag && (
|
||||
// /dashboard/conversations reads its own `?tree=<id>` deep-link param
|
||||
// from a fresh mount too (useState(() => searchParams.get("tree")) in
|
||||
// that page) -- same full-navigation reasoning as the parent-log link
|
||||
// above. sessionTag is the same conv_<id> the conversations list and
|
||||
// /api/conversations/[id]/tree both key on.
|
||||
<a
|
||||
href={`/dashboard/conversations?tree=${encodeURIComponent(liveDetail.sessionTag)}`}
|
||||
className="flex items-center gap-1 text-[11px] text-text-muted hover:text-primary transition-colors"
|
||||
title={`Open conversation ${liveDetail.sessionTag}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">forum</span>
|
||||
view conversation
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* never turn into a second failure on the response path.
|
||||
*/
|
||||
import { saveCallLog, saveRequestUsage } from "@/lib/usageDb";
|
||||
import { redactVideoTranscriptFieldsForLog } from "@/lib/guardrails/videoBridgeSnapshotRedaction";
|
||||
|
||||
export interface RejectedRequestUsageInput {
|
||||
status: number;
|
||||
@@ -82,7 +83,12 @@ export async function recordRejectedRequestUsage(input: RejectedRequestUsageInpu
|
||||
duration,
|
||||
tokens: {},
|
||||
error: error || null,
|
||||
requestBody,
|
||||
// #12150 P2 item 7: this request was rejected BEFORE the guardrail chain ran
|
||||
// (circuit-breaker-open / combo-exhausted), so the video-bridge guardrail
|
||||
// never redacted the transcript. Redact defensively here — a no-op clone for
|
||||
// any non-video body, structured field substitution (never bypassable by cue
|
||||
// content) for a video one. See videoBridgeSnapshotRedaction.ts.
|
||||
requestBody: requestBody == null ? requestBody : redactVideoTranscriptFieldsForLog(requestBody),
|
||||
comboName,
|
||||
comboStepId,
|
||||
comboExecutionKey,
|
||||
|
||||
@@ -136,6 +136,68 @@ test("combo-exhausted rejection persists the client request body for dashboard i
|
||||
});
|
||||
});
|
||||
|
||||
// #12150 P2 item 7: recordRejectedRequestUsage persists the raw client body for
|
||||
// a request rejected BEFORE the guardrail chain runs (circuit-breaker-open /
|
||||
// combo-exhausted), so the video-bridge guardrail never got a chance to redact
|
||||
// the transcript. The body is persisted defensively through
|
||||
// redactVideoTranscriptFieldsForLog, so a rejected video request's stored log
|
||||
// never retains the raw transcript cues.
|
||||
test("#12150 P2 item 7: a rejected request's persisted body has its video transcript redacted", async () => {
|
||||
const SECRET = "top secret cue text";
|
||||
await recordRejectedRequestUsage({
|
||||
status: 503,
|
||||
model: "default",
|
||||
requestedModel: "default",
|
||||
provider: "-",
|
||||
endpoint: "/v1/chat/completions",
|
||||
error: "[503] Pipeline gate rejected",
|
||||
apiKeyId: "key-video-reject",
|
||||
apiKeyName: "video-reject-test",
|
||||
correlationId: "corr-video-reject",
|
||||
startTime: Date.now() - 10,
|
||||
requestBody: {
|
||||
model: "default",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "look at this video" },
|
||||
{
|
||||
type: "input_video",
|
||||
video_url: "https://example.com/clip.mp4",
|
||||
transcript: { cues: [{ text: SECRET, startSeconds: 0, endSeconds: 2 }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
let rejected: { id: string } | undefined;
|
||||
for (let i = 0; i < 50 && !rejected; i++) {
|
||||
const logs = await callLogs.getCallLogs({});
|
||||
const list = (logs.logs ?? logs) as Array<{ apiKeyName?: string | null }>;
|
||||
const found = (list ?? []).find((l) => l.apiKeyName === "video-reject-test");
|
||||
if (found) rejected = found as unknown as { id: string };
|
||||
else await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
assert.ok(rejected, "expected a call_logs row for the rejected video request");
|
||||
|
||||
const detail = await callLogs.getCallLogById(rejected.id);
|
||||
assert.ok(detail, "expected to load the call log detail");
|
||||
assert.equal(
|
||||
JSON.stringify(detail!.requestBody).includes(SECRET),
|
||||
false,
|
||||
"the rejected request's persisted body must not retain the raw video transcript"
|
||||
);
|
||||
const transcriptField = (
|
||||
detail!.requestBody as {
|
||||
messages: Array<{ content: Array<{ transcript?: unknown }> }>;
|
||||
}
|
||||
).messages[0].content[1].transcript;
|
||||
assert.equal(transcriptField, "[redacted-video-transcript]");
|
||||
});
|
||||
|
||||
test("combo-exhausted rejection without a request body still logs cleanly (no request body available)", async () => {
|
||||
await recordRejectedRequestUsage({
|
||||
status: 503,
|
||||
|
||||
106
tests/unit/ui/log-detail-conversation-link-12646.test.tsx
Normal file
106
tests/unit/ui/log-detail-conversation-link-12646.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Guard for #12646: a log entry's Conversation Context header links to the
|
||||
* conversation that owns it.
|
||||
*
|
||||
* The link is a plain `<a href>` on purpose, not a client-side route push:
|
||||
* /dashboard/conversations reads its own `?tree=<id>` deep-link param from a
|
||||
* fresh mount (`useState(() => searchParams.get("tree"))`), so it only picks
|
||||
* the param up on a full navigation. A future refactor to a Next `<Link>`
|
||||
* would silently stop opening the right tree — which is what this test pins.
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const RequestLoggerDetail = (await import("../../../src/shared/components/RequestLoggerDetail.tsx"))
|
||||
.default;
|
||||
|
||||
let container: HTMLElement;
|
||||
let root: Root;
|
||||
|
||||
function baseLog(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "log-12646",
|
||||
status: 200,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
model: "gpt-test",
|
||||
provider: "openai",
|
||||
timestamp: new Date().toISOString(),
|
||||
duration: 42,
|
||||
tokens: { in: 1, out: 2 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
// The section short-circuits with `if (allTurns.length === 0) return null`, so the
|
||||
// fixture needs a request body that normalizes into at least one turn — otherwise
|
||||
// the whole header, link included, never mounts and the assertions would pass or
|
||||
// fail for the wrong reason.
|
||||
const REQUEST_BODY_WITH_A_TURN = {
|
||||
model: "gpt-test",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
};
|
||||
|
||||
async function renderDetail(detail: Record<string, unknown>) {
|
||||
const log = baseLog();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RequestLoggerDetail
|
||||
log={log}
|
||||
detail={{ ...log, requestBody: REQUEST_BODY_WITH_A_TURN, ...detail }}
|
||||
loading={false}
|
||||
debugEnabled={false}
|
||||
onClose={noop}
|
||||
onCopy={async () => true}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function conversationLink(): HTMLAnchorElement | null {
|
||||
return container.querySelector<HTMLAnchorElement>('a[href^="/dashboard/conversations?tree="]');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
});
|
||||
|
||||
describe("log detail — Conversation Context link (#12646)", () => {
|
||||
it("deep-links to the owning conversation when the detail carries a sessionTag", async () => {
|
||||
await renderDetail({ sessionTag: "conv_abc123" });
|
||||
|
||||
const link = conversationLink();
|
||||
expect(link).not.toBeNull();
|
||||
expect(link!.getAttribute("href")).toBe("/dashboard/conversations?tree=conv_abc123");
|
||||
});
|
||||
|
||||
it("percent-encodes a sessionTag that is not URL-safe", async () => {
|
||||
await renderDetail({ sessionTag: "conv_a b/c?d" });
|
||||
|
||||
expect(conversationLink()!.getAttribute("href")).toBe(
|
||||
`/dashboard/conversations?tree=${encodeURIComponent("conv_a b/c?d")}`
|
||||
);
|
||||
});
|
||||
|
||||
it("renders no conversation link when the detail has no sessionTag", async () => {
|
||||
await renderDetail({});
|
||||
|
||||
expect(conversationLink()).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user