Compare commits

...

5 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
9d1a896c60 fix(tests): retire dead model ids from the chat-pipeline integration suite (base-red #12581) (#12670)
Merged. It does what it says, and it also uncovered something — details below so the follow-up is not mistaken for a regression from this PR.

Measured on `release/v3.8.51`, `tests/integration/chat-pipeline.test.ts`:

| | line 580 | line 994 | line 1599 |
|---|---|---|---|
| tip | `410 !== 200` | `410 !== 200` | `502 !== 200` |
| tip + this PR | passes | passes | passes |

All three were retired model ids reaching the router and coming back 410/502. Swapping them for live ones is exactly the right fix and takes the suite from 25/28 to 27/28.

**The one that remains, and why it is not yours:** with the 410 gone, `chat pipeline persists Codex responses cache and reasoning tokens to call logs` now runs past `assert.equal(response.status, 200)` and reaches line 592, where `callLog.provider` is `openai` and the test expects `codex`. That assertion was simply never reached before — the 410 short-circuited the test at line 580.

I checked whether the model id chosen here was the cause, since `gpt-5.6-sol` is declared by 12 providers (`openai`, `github`, `cursor`, `kiro`, …). It is not: re-running with `gpt-5.3-codex-spark`, which only the `codex` provider declares, produces the identical `openai !== codex`. So it is provider resolution or the `seedConnection("codex")` harness, not catalog ambiguity. I reverted that experiment — this merged exactly as you wrote it.

Filing that as its own issue with the trace.
2026-09-05 03:15:30 -03:00
Diego Rodrigues de Sa e Souza
a9f7598c60 feat(db): fail-closed previous_response_id continuation for redacted video turns (#12150 P2b) (#12707)
Merged, with one column-reconciliation gap closed.

The fail-closed reasoning is right and the comments carry it well: a stored snapshot whose cues were replaced by `[redacted-video-transcript]` must not be rehydrated as continuation history, because forwarding placeholder text upstream as if it were the client's real turn is worse than making the client resend. Treating it exactly like `previous_response_not_found` means no new client-visible behaviour to document. Migration 173 does not collide — the tip runs to 172.

**What I added:** `video_content_removed` to `ensureCallLogsColumns` in `src/lib/db/schemaColumns.ts`, plus a case in `tests/unit/db-schema-columns-split.test.ts`.

`resolvePreviousResponseState` now SELECTs that column on every `previous_response_id` lookup. Migration 173 creates it, but this repo carries a separate reconciliation path for lineages that skipped a migration — and on such a database the SELECT would throw `no such column: video_content_removed` instead of failing closed. That is the same hole #12470 closed for `provider_connections.last_ping_at` earlier today, so the pattern was fresh. Verified red-then-green: stubbing the new reconciliation out drops the suite to 8/9; restored, 9/9.

Validated on `release/v3.8.51`: `responses-continuation-store`, `save-call-log-persistence`, `video-bridge-log-redaction` and `db-schema-columns-split` all green (54 focused tests, 0 failures). `typecheck:core` and `lint` clean. The integration run logs `[DB] Added call_logs.video_content_removed column`, which is the reconciliation firing on a fresh test database.
2026-09-05 03:15:25 -03:00
Diego Rodrigues de Sa e Souza
d345520d72 fix(dashboard): read the combos usage-guide dismissal from an external store (base-red #12581) (#12671)
Merged. This removes the cause that #12607 had to freeze.

`react-hooks/set-state-in-effect` on this file was living in `config/quality/eslint-suppressions.json` as a frozen count of 1 — the lint was green because the violation was suppressed, not because it was gone. `useSyncExternalStore` is the sanctioned shape for exactly this problem: `getServerSnapshot` supplies the SSR-safe default, `getSnapshot` reads localStorage after hydration, and the tree commits once instead of twice. The `storage` listener keeping other tabs in sync is a real bonus.

The detail that makes this correct rather than merely lint-clean: you kept "hide for now" and "hide forever" as separate concepts — `usageGuideHiddenForNow` stays per-mount local state while only the persisted dismissal goes through the store. A naive conversion would have collapsed them and made the temporary hide survive a reload.

Three things I added before merging:

1. **Dropped the `react-hooks/set-state-in-effect` entry from the suppressions file.** With the cause gone it becomes a stale allowlist entry, which is what the Fase 6A.3 stale-enforcement is built to flag. Verified: `eslint` on the file now reports only the 6 pre-existing `no-unused-vars`, which stay frozen.
2. **Updated the rationale comment above the hook** — it still described "correct it client-only, after hydration, in an effect", which is the shape you just removed.
3. **Rebaselined `combos/page.tsx` 5018 → 5066** in `file-size-baseline.json` with a dated annotation. The +48 lines are the module-scope store helpers; the cap is pre-authorized for legitimate growth and this is as legitimate as it gets.

Validated on `release/v3.8.51`: `check-file-size` OK, `lint` clean, `typecheck:core` and `check:dashboard-typecheck` clean (207 pre-existing, all within baseline).
2026-09-05 03:15:03 -03:00
Diego Rodrigues de Sa e Souza
7b2c9b5548 fix(sse): redact video transcript in pre-guardrail rejected-request logs (#12150 P2 item 7) (#12710)
Merged. Focused, correct, and tested.

`recordRejectedRequestUsage` runs on the path where the request never reached the guardrail chain — circuit-breaker-open and combo-exhausted rejections — so the video-bridge guardrail never got the chance to rewrite the transcript, and the raw cues went straight into `call_logs`. Routing the body through `redactVideoTranscriptFieldsForLog` at the persistence boundary is the right place: a no-op clone for non-video bodies, structured field substitution for video ones, and not bypassable by cue content.

The `requestBody == null ? requestBody : …` guard keeps the existing "no body available" case behaving exactly as before, which the neighbouring test still covers.

Validated on `release/v3.8.51`: `tests/unit/rejected-request-usage.test.ts` green, including the new case asserting the secret cue text does not survive into the persisted detail and that the field reads `[redacted-video-transcript]`. `typecheck:core` and `lint` clean.
2026-09-05 03:14:49 -03:00
Diego Rodrigues de Sa e Souza
ec4f951e39 test(ci): pin the openapi-security-tiers two-arm contract with an executing gate test (#12581) (#12652)
Merged as a reduced diff, and worth recording why.

The two-arm `ALWAYS_PROTECTED` read this PR proposed had already landed in #12605 while this branch was open — the tip carries `coveredByAlwaysProtected()` with both arms and the new error wording. I ran the gate on the current tip to be sure: `PASS — all security tier annotations match routeGuard.ts`. Merging the whole branch would have reintroduced the same logic under a different comment.

What was genuinely missing, and is what merged:

- **`tests/unit/openapi-security-tiers-gate.test.ts`** — executes the real gate and asserts exit 0 with no "NOT covered" line. #12605 fixed the defect but left no guard, so the LOCAL_ONLY-arm bug (#12350) could reappear on the ALWAYS_PROTECTED arm exactly as it did the first time. 1/1 green.
- **The parse guard** — `ALWAYS_PROTECTED_PATTERNS.length === 0` now fails the constant-parse check with its own count in the message. Without it, a regex array that stops parsing degrades into "every pattern-covered route is an annotation mismatch" instead of saying so.

A note for the record: my first read of this PR was wrong. I ran the gate in the main checkout, which was 11 commits behind `origin/release/v3.8.51`, saw the pre-#12605 failure, and classified this as fixing a live red. It was not — the checkout was stale. Corrected before anything was merged.
2026-09-05 03:14:46 -03:00
19 changed files with 434 additions and 34 deletions

View File

@@ -0,0 +1 @@
- **fix(dashboard):** The Combos page usage guide now reads its dismissal through `useSyncExternalStore` instead of correcting SSR state inside an effect, removing an extra commit of the page tree on every load (and the `react-hooks/set-state-in-effect` error it raised).

View File

@@ -854,9 +854,6 @@
"src/app/(dashboard)/dashboard/combos/page.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 6
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": {

View File

@@ -434,7 +434,7 @@
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1335,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1344,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3186,
"src/app/(dashboard)/dashboard/combos/page.tsx": 5018,
"src/app/(dashboard)/dashboard/combos/page.tsx": 5066,
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1319,
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2491,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1631,
@@ -643,5 +643,6 @@
"_rebaseline_2026_09_03_error_boundary_campaign": "Campanha de error-boundary (#12431 #12438 #12444 #12454 #12455 #12456 #12457 #12458 #12459 #12465 #12466 #12467 #12469 #12435), medido no tip com os 14 mergeados. open-sse/executors/codex.ts 1499->1505: os primeiros 4 (1499->1503) sao DRIFT ANTERIOR a esta campanha, ja presente no tip antes dela; os 2 ultimos (1503->1505) sao do #12444, que fecha o boundary de falha da resposta do Codex. Absorver o drift junto foi inevitavel porque o cap e um numero so, mas fica registrado aqui que 4 das 6 linhas nao sao desta leva. open-sse/vendor/codex-chatgpt-web/bridge.ts 1322->1335 (+13): tambem do #12444, no mesmo caminho de falha. NAO cobre open-sse/utils/stream.ts, que segue violando por drift anterior e independente.",
"_rebaseline_2026_09_03_12352_apikey_acl": "PR #12352 (fix/api-key-create-acl-12275) crescimento proprio: src/lib/db/apiKeys.ts 1610->1625 (+15). A criacao de API key descartava a ACL enviada no payload; preservar essa ACL exige carregar e persistir o conjunto no mesmo chokepoint de INSERT do modulo de dominio, sem extracao possivel sem partir a funcao de criacao ao meio. Coberto pelos testes do proprio PR (54/54 focados na leva).",
"_rebaseline_2026_09_03_houminxi_combo_stacked": "Leva HouMinXi (#12624 #12626 #12632 #12637): open-sse/services/combo.ts 4075->4080 (+5), medido no tip com os quatro mergeados. Cada PR registrou o proprio crescimento contra o tip de onde forkou (o #12637 ja subira o cap para 4075); as 5 linhas restantes so aparecem quando eles empilham, porque mais de um toca o mesmo chokepoint de scoring reset-aware em combo.ts. Fiacao em ponto existente, sem extracao possivel sem partir a funcao de selecao de alvos. Coberto por combo-strategies e reset-aware-request-scope-12600 (119/119 focados na leva).",
"_rebaseline_2026_09_04_12641_continuation_effective_input": "PR #12641 crescimento proprio: src/sse/handlers/chat.ts 2450->2454 (+4). A continuacao por previous_response_id encadeava a partir de clientRawRequest.body.input, que e capturado ANTES da reconstrucao do proprio chat.ts; quando o turno anterior ja era uma continuacao, esse campo guarda so o delta do cliente, e o erro se acumulava a cada salto ate a reconstrucao virar itens de tool sem prefixo. Persistir o input EFETIVO exige as linhas no ponto onde a reconstrucao termina, dentro do fluxo de despacho. Coberto por tests/unit/responses-continuation-store.test.ts (22/22 focados na leva)."
"_rebaseline_2026_09_04_12641_continuation_effective_input": "PR #12641 crescimento proprio: src/sse/handlers/chat.ts 2450->2454 (+4). A continuacao por previous_response_id encadeava a partir de clientRawRequest.body.input, que e capturado ANTES da reconstrucao do proprio chat.ts; quando o turno anterior ja era uma continuacao, esse campo guarda so o delta do cliente, e o erro se acumulava a cada salto ate a reconstrucao virar itens de tool sem prefixo. Persistir o input EFETIVO exige as linhas no ponto onde a reconstrucao termina, dentro do fluxo de despacho. Coberto por tests/unit/responses-continuation-store.test.ts (22/22 focados na leva).",
"_rebaseline_2026_09_05_12671_combos_usage_guide_external_store": "combos/page.tsx 5018 -> 5066: #12671 replaces the effect-based localStorage read with useSyncExternalStore; the +48 lines are the store helpers (subscribe/getSnapshot/getServerSnapshot/emit) hoisted to module scope, which is the sanctioned shape and what let the react-hooks/set-state-in-effect suppression be dropped."
}

View File

@@ -1097,6 +1097,10 @@ export async function handleChatCore({
// #12150 P1b surface 1: undefined for every non-video request (byte-identical
// to before this param existed) — see applyVideoBridgeLogRedaction.
videoBridgeLogRedaction: (videoBridgeLog as VideoBridgeLogParam | undefined)?.redaction,
// #12150 P2 surface 2: mark the persisted call_logs row so
// resolvePreviousResponseState refuses to rehydrate a snapshot whose video
// transcript was redacted. false for every non-video request.
videoContentRemoved: videoBridgeObserved,
});
// Primary path: merge client model id + alias target so config on either key applies; resolved

View File

@@ -250,6 +250,15 @@ export type PersistAttemptLogsContext = {
* path) is never touched. Omitted/empty for every non-video request.
*/
videoBridgeLogRedaction?: VideoBridgeLogRedactionEntry[];
/**
* #12150 P2 surface 2: true when the video-bridge guardrail observed and
* rewrote video parts on this request, so the persisted client snapshot had
* its transcript cues structurally redacted (videoBridgeObserved in
* chatCore.ts). Written to the `call_logs.video_content_removed` marker so
* `resolvePreviousResponseState` refuses to rehydrate this row as continuation
* history. Omitted/false for every non-video request.
*/
videoContentRemoved?: boolean;
};
function toConnectionId(value: unknown): string | null {
@@ -368,6 +377,7 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt
modelPinned,
sessionTag,
videoBridgeLogRedaction,
videoContentRemoved,
} = ctx;
const initialConnectionId = toConnectionId(connectionId);
const finalConnectionId = toConnectionId(credentials?.connectionId) || initialConnectionId;
@@ -499,6 +509,7 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt
modelPinned: modelPinned || false,
sessionTag: sessionTag || null,
responseId: extractResponsesId(sourceFormat, clientResponse),
videoContentRemoved: videoContentRemoved || false,
}).catch(() => {});
// Emit the terminal request-lifecycle event to the live dashboard bus. `request.started`

View File

@@ -115,12 +115,14 @@ const ALWAYS_PROTECTED_PATTERNS = parsePatterns("ALWAYS_PROTECTED_API_PATTERNS")
if (
LOCAL_ONLY_PREFIXES.length === 0 ||
LOCAL_ONLY_PATTERNS.length === 0 ||
ALWAYS_PROTECTED_PATHS.length === 0
ALWAYS_PROTECTED_PATHS.length === 0 ||
ALWAYS_PROTECTED_PATTERNS.length === 0
) {
console.error(
`[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants ` +
`(prefixes=${LOCAL_ONLY_PREFIXES.length}, patterns=${LOCAL_ONLY_PATTERNS.length}, ` +
`alwaysProtected=${ALWAYS_PROTECTED_PATHS.length})`
`alwaysProtected=${ALWAYS_PROTECTED_PATHS.length}, ` +
`alwaysProtectedPatterns=${ALWAYS_PROTECTED_PATTERNS.length})`
);
process.exit(1);
}

View File

@@ -1,6 +1,15 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef, memo, Suspense } from "react";
import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
useSyncExternalStore,
memo,
Suspense,
} from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
@@ -388,6 +397,42 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = {
const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide";
// The dismissal lives in localStorage, which SSR cannot read: a lazy useState
// initializer would render "not dismissed" on the server and the real value on
// the client, and correcting that in an effect is a synchronous setState inside
// an effect (react-hooks/set-state-in-effect) that costs an extra commit of this
// whole tree. useSyncExternalStore is the sanctioned shape for exactly this —
// getServerSnapshot supplies the SSR-safe default, getSnapshot reads the store
// after hydration, and the two handlers below notify subscribers instead of
// setting state. The `storage` listener keeps other tabs in sync for free.
const usageGuideListeners = new Set<() => void>();
function subscribeUsageGuide(onStoreChange: () => void): () => void {
usageGuideListeners.add(onStoreChange);
globalThis.addEventListener?.("storage", onStoreChange);
return () => {
usageGuideListeners.delete(onStoreChange);
globalThis.removeEventListener?.("storage", onStoreChange);
};
}
function emitUsageGuideChange(): void {
for (const listener of usageGuideListeners) listener();
}
function getUsageGuideSnapshot(): boolean {
try {
return globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1";
} catch {
// Storage access errors (privacy mode / restricted environments) show the guide.
return true;
}
}
function getUsageGuideServerSnapshot(): boolean {
return true;
}
// Pure predicate hoisted out of the page component to keep its cyclomatic budget flat
// (check:complexity new-code mode).
function isStaleIntelligentSelection(
@@ -766,16 +811,18 @@ function CombosPageContent() {
// real stored value -- exactly the kind of source React's hydration
// mismatch check is built to catch, and in dev mode a mismatch forces a
// full client-only re-render of this tree, discarding whatever the fetch
// effects below had already populated. Start with the SSR-safe default on
// both passes and correct it client-only, after hydration, in an effect.
const [showUsageGuide, setShowUsageGuide] = useState(true);
useEffect(() => {
try {
setShowUsageGuide(globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1");
} catch {
// Ignore storage access errors (privacy mode / restricted environments)
}
}, []);
// effects below had already populated. useSyncExternalStore renders the
// SSR-safe default on both passes and switches to the stored value at
// hydration, without a second commit — see the store helpers above.
const usageGuideNotDismissed = useSyncExternalStore(
subscribeUsageGuide,
getUsageGuideSnapshot,
getUsageGuideServerSnapshot
);
// "Hide" (as opposed to "hide forever") is intentionally per-mount: it is not
// persisted, and remounting the page brings the guide back — same as before.
const [usageGuideHiddenForNow, setUsageGuideHiddenForNow] = useState(false);
const showUsageGuide = usageGuideNotDismissed && !usageGuideHiddenForNow;
const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState("");
const [creatingKimiPreset, setCreatingKimiPreset] = useState(false);
const [comboDragIndex, setComboDragIndex] = useState(null);
@@ -1006,17 +1053,18 @@ function CombosPageContent() {
};
const handleHideUsageGuideForever = () => {
setShowUsageGuide(false);
try {
globalThis.localStorage?.setItem(COMBO_USAGE_GUIDE_STORAGE_KEY, "1");
} catch {}
emitUsageGuideChange();
};
const handleShowUsageGuide = () => {
setShowUsageGuide(true);
try {
globalThis.localStorage?.removeItem(COMBO_USAGE_GUIDE_STORAGE_KEY);
} catch {}
setUsageGuideHiddenForNow(false);
emitUsageGuideChange();
};
const handleFilterChange = (nextFilter) => {
@@ -1149,7 +1197,7 @@ function CombosPageContent() {
{showUsageGuide && (
<ComboUsageGuide
onHide={() => setShowUsageGuide(false)}
onHide={() => setUsageGuideHiddenForNow(true)}
onHideForever={handleHideUsageGuideForever}
onCreateCombo={() => setShowCreateModal(true)}
/>

View File

@@ -0,0 +1,16 @@
-- 173: mark call-log rows whose persisted client-request snapshot had its
-- video transcript content structurally redacted (#12150 P2 surface 2).
--
-- Set to 1 by the call-log write path when the video-bridge guardrail observed
-- and rewrote video parts on this request (see videoBridgeObserved in
-- open-sse/handlers/chatCore.ts). resolvePreviousResponseState
-- (src/lib/db/responsesContinuationStore.ts) refuses to rehydrate a row so
-- marked: the stored snapshot carries [redacted-video-transcript] placeholders
-- in place of the client's real cues, so reconstructing a continuation off it
-- would forward the placeholder text upstream as if it were real history.
-- Failing closed makes the client resend full history instead, exactly like a
-- real previous_response_not_found.
--
-- Default 0 (NOT NULL): every existing and non-video row is "nothing removed".
ALTER TABLE call_logs ADD COLUMN video_content_removed INTEGER NOT NULL DEFAULT 0;

View File

@@ -66,17 +66,27 @@ export function resolvePreviousResponseState(
const db = getDbInstance();
const row = db
.prepare(
`SELECT artifact_relpath, api_key_id FROM call_logs
`SELECT artifact_relpath, api_key_id, video_content_removed FROM call_logs
WHERE response_id = ? AND detail_state = 'ready'
ORDER BY timestamp DESC LIMIT 1`
)
.get(responseId) as { artifact_relpath: string | null; api_key_id: string | null } | undefined;
.get(responseId) as
| { artifact_relpath: string | null; api_key_id: string | null; video_content_removed: number }
| undefined;
if (!row || !row.artifact_relpath) return null;
// Tenant isolation: a response id is only ever handed back to the API key
// that created it. A stored row with no api_key_id at all (no-log/legacy)
// can never be resolved by any key -- fail closed rather than guess.
if (!apiKeyId || row.api_key_id !== apiKeyId) return null;
// #12150 P2 surface 2: the persisted clientRawRequest snapshot on this row had
// its video transcript cues structurally redacted to [redacted-video-transcript]
// before storage (videoBridgeSnapshotRedaction, marker written by the call-log
// path). The stored input therefore no longer carries the client's real cue
// text -- reconstructing a continuation off it would forward the placeholder
// upstream as if it were genuine history. Fail closed so the client resends
// full history, exactly like a real previous_response_not_found.
if (row.video_content_removed === 1) return null;
const { artifact, state } = readCallArtifact(row.artifact_relpath);
if (state !== "ready" || !artifact?.pipeline) return null;

View File

@@ -240,6 +240,14 @@ export function ensureCallLogsColumns(db: SqliteDatabase) {
db.exec("ALTER TABLE call_logs ADD COLUMN request_summary TEXT DEFAULT NULL");
console.log("[DB] Added call_logs.request_summary column");
}
// added by 173_call_logs_video_content_removed; back-filled here because
// resolvePreviousResponseState SELECTs it on every continuation lookup — a
// lineage that skipped the migration would throw "no such column" there
// rather than fail closed. Same hole #12470 closed for provider_connections.
if (!columnNames.has("video_content_removed")) {
db.exec("ALTER TABLE call_logs ADD COLUMN video_content_removed INTEGER NOT NULL DEFAULT 0");
console.log("[DB] Added call_logs.video_content_removed column");
}
if (!columnNames.has("correlation_id")) {
db.exec("ALTER TABLE call_logs ADD COLUMN correlation_id TEXT DEFAULT NULL");
console.log("[DB] Added call_logs.correlation_id column");

View File

@@ -522,6 +522,11 @@ async function saveCallLogOperation(entry: any): Promise<void> {
// this row's artifact for OmniRoute-native continuation. See
// src/lib/db/responsesContinuationStore.ts.
responseId: typeof entry.responseId === "string" ? entry.responseId : null,
// #12150 P2 surface 2: 1 when this request's persisted client snapshot had
// its video transcript cues structurally redacted, so
// resolvePreviousResponseState refuses to rehydrate it as continuation
// history. See src/lib/db/responsesContinuationStore.ts.
videoContentRemoved: entry.videoContentRemoved ? 1 : 0,
};
const requestSummary = noLogEnabled
@@ -570,7 +575,8 @@ async function saveCallLogOperation(entry: any): Promise<void> {
combo_name, combo_step_id, combo_execution_key, error_summary, detail_state,
artifact_relpath, artifact_size_bytes, artifact_sha256,
has_request_body, has_response_body, has_pipeline_details, request_summary,
correlation_id, model_pinned, session_tag, response_id, error_type
correlation_id, model_pinned, session_tag, response_id, error_type,
video_content_removed
)
VALUES (
@id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider,
@@ -581,7 +587,8 @@ async function saveCallLogOperation(entry: any): Promise<void> {
@comboName, @comboStepId, @comboExecutionKey, @errorSummary, @detailState,
@artifactRelPath, @artifactSizeBytes, @artifactSha256,
@hasRequestBody, @hasResponseBody, @hasPipelineDetails, @requestSummary,
@correlationId, @modelPinned, @sessionTag, @responseId, @errorType
@correlationId, @modelPinned, @sessionTag, @responseId, @errorType,
@videoContentRemoved
)
`
).run({

View File

@@ -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,

View File

@@ -170,7 +170,7 @@ function buildOpenAIToolCallResponse({
);
}
function buildClaudeResponse(text = "ok", model = "claude-3-5-sonnet-20241022") {
function buildClaudeResponse(text = "ok", model = "claude-sonnet-4-6") {
return new Response(
JSON.stringify({
id: "msg_json",
@@ -286,7 +286,7 @@ function buildOpenAIStreamResponse(text = "streamed from openai") {
function buildOpenAIResponsesSSE({
text = "responses streamed from codex",
model = "gpt-5.1-codex",
model = "gpt-5.6-sol",
usage = null,
} = {}) {
return new Response(
@@ -567,7 +567,7 @@ test("chat pipeline persists Codex responses cache and reasoning tokens to call
buildRequest({
url: "http://localhost/v1/responses",
body: {
model: "codex/gpt-5.1-codex",
model: "codex/gpt-5.6-sol",
stream: false,
input: "Persist cache + reasoning usage",
},
@@ -983,7 +983,7 @@ test("chat pipeline translates OpenAI requests to Claude and returns OpenAI-shap
const response = await handleChat(
buildRequest({
body: {
model: "claude/claude-3-5-sonnet-20241022",
model: "claude/claude-sonnet-4-6",
stream: false,
messages: [{ role: "user", content: "Hello Claude" }],
},
@@ -1566,7 +1566,7 @@ test("chat pipeline falls back across combo models when the first provider fails
name: "combo-fallback",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0 },
models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022"],
models: ["openai/gpt-4o-mini", "claude/claude-sonnet-4-6"],
});
const attempts = [];

View File

@@ -11,6 +11,7 @@ import {
ensureUsageHistoryColumns,
ensureProviderConnectionsColumns,
ensureProxyLogsColumns,
ensureCallLogsColumns,
hasColumn,
hasTable,
quoteIdentifier,
@@ -182,3 +183,27 @@ test("ensureProviderConnectionsColumns back-fills last_ping columns on a pre-123
db.close?.();
}
});
// #12150 P2b: `resolvePreviousResponseState` SELECTs `video_content_removed` on
// every previous_response_id lookup. Migration 173 adds it, but a lineage that
// skipped 173 would raise "no such column" there instead of failing closed, so
// the reconciliation has to carry it too — the hole #12470 closed for
// provider_connections.
test("ensureCallLogsColumns back-fills video_content_removed on a pre-173 lineage", () => {
const db = openMemoryDb();
try {
db.exec("CREATE TABLE call_logs (id TEXT PRIMARY KEY, timestamp TEXT)");
assert.equal(hasColumn(db, "call_logs", "video_content_removed"), false);
ensureCallLogsColumns(db);
assert.equal(hasColumn(db, "call_logs", "video_content_removed"), true);
const row = db
.prepare("SELECT video_content_removed AS v FROM call_logs WHERE id = ?")
.get("missing") as { v: number } | undefined;
assert.equal(row, undefined, "empty table — the column just has to be selectable");
assert.doesNotThrow(() => ensureCallLogsColumns(db));
} finally {
db.close?.();
}
});

View File

@@ -0,0 +1,38 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const GATE = join(ROOT, "scripts", "check", "check-openapi-security-tiers.mjs");
function runGate(): { code: number; out: string } {
try {
const out = execFileSync(process.execPath, [GATE], {
cwd: ROOT,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
return { code: 0, out };
} catch (err) {
const e = err as { status?: number; stdout?: string; stderr?: string };
return { code: e.status ?? 1, out: `${e.stdout ?? ""}${e.stderr ?? ""}` };
}
}
// routeGuard protects a path when EITHER list matches — `isAlwaysProtectedPath`
// ORs ALWAYS_PROTECTED_API_PATHS with ALWAYS_PROTECTED_API_PATTERNS. The gate
// used to read only the prefix array, so every regex-covered route was reported
// as an annotation mismatch: the four `{claude,codex}-auth/{export,apply-local}`
// routes turned release/v3.8.51 red while being correctly protected at runtime.
// Same defect class the LOCAL_ONLY arm already had (#12350).
test("openapi-security-tiers accepts routes covered only by ALWAYS_PROTECTED_API_PATTERNS", () => {
const { code, out } = runGate();
assert.ok(
!/has x-always-protected but is NOT/.test(out),
`gate reported an always-protected route as uncovered:\n${out}`
);
assert.equal(code, 0, `gate must pass on a clean tree, got exit ${code}:\n${out}`);
});

View File

@@ -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,

View File

@@ -27,13 +27,15 @@ function insertCallLog(row: {
apiKeyId: string | null;
detailState: string;
artifactRelPath: string | null;
videoContentRemoved?: 0 | 1;
}) {
const db = core.getDbInstance();
db.prepare(
`INSERT INTO call_logs
(id, timestamp, method, path, status, model, provider, account, duration,
tokens_in, tokens_out, api_key_id, detail_state, artifact_relpath, response_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
tokens_in, tokens_out, api_key_id, detail_state, artifact_relpath, response_id,
video_content_removed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
row.id,
new Date().toISOString(),
@@ -49,7 +51,8 @@ function insertCallLog(row: {
row.apiKeyId,
row.detailState,
row.artifactRelPath,
row.responseId
row.responseId,
row.videoContentRemoved ?? 0
);
}
@@ -398,6 +401,65 @@ test("resolvePreviousResponseState fails closed on an empty output array even wi
assert.equal(store.resolvePreviousResponseState("resp_gen-empty-output", "key-1"), null);
});
test("resolvePreviousResponseState fails closed when the row had video content removed (#12150 P2)", () => {
// #12150 P2 surface 2: the persisted clientRawRequest snapshot had its video
// transcript cues structurally redacted to [redacted-video-transcript] before
// storage (videoBridgeSnapshotRedaction). The stored input therefore no longer
// carries the client's real cue text -- reconstructing a continuation off it
// would forward the placeholder upstream as if it were genuine history. When the
// owning row is marked video_content_removed=1 this must fail closed (return
// null) so the client resends full history, exactly like previous_response_not_found,
// even though the artifact itself is otherwise a perfectly resolvable 'ready' row.
insertCallLog({
id: "log-video-removed",
responseId: "resp_video_removed",
apiKeyId: "key-1",
detailState: "ready",
artifactRelPath: "2026-01-01/log-video-removed.json",
videoContentRemoved: 1,
});
writeArtifact("2026-01-01/log-video-removed.json", {
clientRawRequest: {
body: {
input: [{ type: "message", role: "user", content: "[redacted-video-transcript]" }],
},
},
providerRequest: { body: { input: [] } },
clientResponse: {
id: "resp_video_removed",
output: [{ type: "message", role: "assistant", content: "hello" }],
},
});
assert.equal(store.resolvePreviousResponseState("resp_video_removed", "key-1"), null);
});
test("resolvePreviousResponseState still resolves a normal row (video_content_removed=0)", () => {
// Guard the fail-closed above does not over-fire: an ordinary row (the default
// 0) resolves exactly as before.
insertCallLog({
id: "log-video-notremoved",
responseId: "resp_video_notremoved",
apiKeyId: "key-1",
detailState: "ready",
artifactRelPath: "2026-01-01/log-video-notremoved.json",
videoContentRemoved: 0,
});
writeArtifact("2026-01-01/log-video-notremoved.json", {
clientRawRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } },
providerRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } },
clientResponse: {
id: "resp_video_notremoved",
output: [{ type: "message", role: "assistant", content: "hello" }],
},
});
assert.deepEqual(store.resolvePreviousResponseState("resp_video_notremoved", "key-1"), {
input: [{ type: "message", role: "user", content: "hi" }],
output: [{ type: "message", role: "assistant", content: "hello" }],
});
});
test("resolvePreviousResponseState returns null when detail logging was never captured for this row", () => {
insertCallLog({
id: "log-5",

View File

@@ -152,6 +152,73 @@ test("saveCallLog persists modelPinned=false as 0", async () => {
db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId);
});
test("call_logs table has video_content_removed column", () => {
const db = getDbInstance();
const columns = db.prepare("PRAGMA table_info(call_logs)").all() as { name: string }[];
const colNames = columns.map((c) => c.name);
assert.ok(
colNames.includes("video_content_removed"),
"call_logs should have video_content_removed column"
);
});
test("saveCallLog persists videoContentRemoved=true as 1 (#12150 P2)", async () => {
const db = getDbInstance();
const testId = `test-videoremoved-${Date.now()}`;
await saveCallLog({
id: testId,
method: "POST",
path: "/v1/responses",
status: 200,
model: "video-model",
provider: "test-provider",
duration: 500,
tokens: { in: 10, out: 5 },
videoContentRemoved: true,
});
const row = db
.prepare("SELECT id, video_content_removed FROM call_logs WHERE id = ?")
.get(testId) as Record<string, unknown>;
assert.ok(row, "row should exist");
assert.equal(
row.video_content_removed,
1,
"video_content_removed should be 1 when videoContentRemoved=true"
);
db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId);
});
test("saveCallLog defaults video_content_removed to 0 when absent (#12150 P2)", async () => {
const db = getDbInstance();
const testId = `test-novideoremoved-${Date.now()}`;
await saveCallLog({
id: testId,
method: "POST",
path: "/v1/chat/completions",
status: 200,
model: "normal-model",
provider: "test-provider",
duration: 500,
tokens: { in: 10, out: 5 },
});
const row = db
.prepare("SELECT id, video_content_removed FROM call_logs WHERE id = ?")
.get(testId) as Record<string, unknown>;
assert.ok(row, "row should exist");
assert.equal(
row.video_content_removed,
0,
"video_content_removed should default to 0 when not provided"
);
db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId);
});
test("getCallLogs returns modelPinned boolean", async () => {
const db = getDbInstance();
const testId = `test-pinned-roundtrip-${Date.now()}`;

View File

@@ -149,6 +149,41 @@ test("persisted requestBody carries the placeholder and never the raw transcript
);
});
test("#12150 P2 surface 2: persistAttemptLogs marks the call_logs row video_content_removed=1 when ctx.videoContentRemoved is true", async () => {
// The continuation fail-closed (resolvePreviousResponseState) depends on this
// marker being written for any request whose stored client snapshot had its
// video transcript redacted. This proves the ctx.videoContentRemoved signal
// reaches the persisted row; the row is the exact thing the continuation store
// reads back.
const id = "video-marker-1";
persistAttemptLogs(
{ status: 200, tokens: { input: 1, output: 2 } },
baseCtx({ pendingRequestId: id, videoContentRemoved: true })
);
const row = await pollForCallLog(id);
assert.ok(row, "call log row should be persisted");
const marker = coreDb
.getDbInstance()
.prepare("SELECT video_content_removed FROM call_logs WHERE id = ?")
.get(id) as { video_content_removed: number };
assert.equal(marker.video_content_removed, 1);
});
test("#12150 P2 surface 2: the marker defaults to 0 for an ordinary (non-video) request", async () => {
const id = "video-marker-control-1";
persistAttemptLogs(
{ status: 200, tokens: { input: 1, output: 2 } },
baseCtx({ pendingRequestId: id })
);
const row = await pollForCallLog(id);
assert.ok(row);
const marker = coreDb
.getDbInstance()
.prepare("SELECT video_content_removed FROM call_logs WHERE id = ?")
.get(id) as { video_content_removed: number };
assert.equal(marker.video_content_removed, 0);
});
test("control: without a redaction map the persisted requestBody keeps the original text (model path untouched)", async () => {
const id = "video-control-1";
persistAttemptLogs(