Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/lib/shadow-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ export function isShadowSourceModel(modelId: string, configured?: unknown): bool
return shadowSourceModels(configured).some(prefix => modelId.startsWith(prefix));
}

/**
* The configured source prefix this model matched, or undefined.
*
* Callers that RECORD the intercepted model must record this rather than the caller's raw
* `modelId`. Matching is by prefix, so `gpt-5.6-luna` plus arbitrary trailing text still
* intercepts — and the raw string is caller-controlled, reaches `usage.jsonl` and `/api/logs`,
* and only passes a pattern-based redactor on the way. A credential family that redactor does
* not recognize survives verbatim. Returning the operator-configured prefix keeps the log
* field inside a set the operator chose, so no caller string is ever persisted.
*/
export function shadowSourceModelPrefix(modelId: string, configured?: unknown): string | undefined {
if (modelId.includes("/")) return undefined;
return shadowSourceModels(configured).find(prefix => modelId.startsWith(prefix));
}

/**
* Decide whether a matching source model should use the opt-in intercept.
*
Expand Down
11 changes: 9 additions & 2 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ export function sidecarOutcomeRecorder(



import { isShadowSourceModel, shouldInterceptShadowCall } from "../../lib/shadow-call";
import { isShadowSourceModel, shadowSourceModelPrefix, shouldInterceptShadowCall } from "../../lib/shadow-call";

export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call";

Expand Down Expand Up @@ -1921,7 +1921,14 @@ async function handleResponsesInner(
if (parsed._rawBody && typeof parsed._rawBody === "object") {
(parsed._rawBody as Record<string, unknown>).reasoning = { effort: "low" };
}
logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString(_sciOriginal);
// Record the operator-configured prefix that matched, NOT the caller's raw model string.
// Matching is by prefix, so a caller can append arbitrary text and still intercept; that
// raw value would then land in usage.jsonl and /api/logs behind a pattern-based redactor
// that does not recognize every credential family. The prefix is a value the operator
// configured, so no caller-controlled string is persisted.
logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString(
shadowSourceModelPrefix(_sciOriginal, _sci.sourceModels),
);
// Helpers must not resume/append into the parent thread's Cursor conversation.
parsed._cursorIsolateConversation = true;
}
Expand Down
35 changes: 35 additions & 0 deletions tests/responses-shadow-intercept.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,41 @@ describe("shadow call intercept request path (issue #311)", () => {
expect(logCtx.shadowCallRewrittenFrom).toBe("gpt-5.6-luna");
});

// The intercept matches by PREFIX, so a caller can append anything and still be intercepted.
// The recorded marker is persisted to usage.jsonl and served from /api/logs, and the runtime
// redactor is pattern-based: a credential family it does not recognize would survive verbatim.
// Recording the operator-configured prefix instead of the caller's raw string removes the
// class, rather than adding one more pattern to a deny-list.
test("the recorded marker is the configured prefix, never the caller's raw model string", async () => {
const logCtx: RequestLogContext = { model: "", provider: "" };
globalThis.fetch = (async () => new Response(JSON.stringify({
choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
}), { status: 200, headers: { "content-type": "application/json" } })) as typeof fetch;

// A Google-shaped key: the runtime redactor has no rule for this family, and the newline
// is stripped before redaction runs, so the old code persisted this string intact.
const smuggled = "gpt-5.6-luna\nAIzaSyA1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6";
await post(interceptConfig(), smuggled, "turn", logCtx);

expect(logCtx.shadowCallRewrittenFrom).toBe("gpt-5.6-luna");
expect(logCtx.shadowCallRewrittenFrom ?? "").not.toContain("AIza");
});

test("a configured non-default prefix is recorded as itself", async () => {
const logCtx: RequestLogContext = { model: "", provider: "" };
globalThis.fetch = (async () => new Response(JSON.stringify({
choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
}), { status: 200, headers: { "content-type": "application/json" } })) as typeof fetch;

const config = interceptConfig();
config.shadowCallIntercept = { enabled: true, model: "grok-4.5", sourceModels: ["gpt-5.4-mini"] };
await post(config, "gpt-5.4-mini-2024-07-18", "turn", logCtx);

expect(logCtx.shadowCallRewrittenFrom).toBe("gpt-5.4-mini");
});

test("leaves gpt-5.6-terra requests unrewritten", async () => {
let sawFetch = false;
globalThis.fetch = (async () => {
Expand Down
Loading