mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-05 14:32:13 +03:00
Compare commits
5 Commits
fix/releas
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d1a896c60 | ||
|
|
a9f7598c60 | ||
|
|
d345520d72 | ||
|
|
7b2c9b5548 | ||
|
|
ec4f951e39 |
@@ -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).
|
||||
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
});
|
||||
|
||||
38
tests/unit/openapi-security-tiers-gate.test.ts
Normal file
38
tests/unit/openapi-security-tiers-gate.test.ts
Normal 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}`);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user