mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
Compare commits
2 Commits
fix/9436-p
...
chloeassis
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f05f754eb0 | ||
|
|
0838bee661 |
@@ -1 +0,0 @@
|
||||
- **fix(sse):** hoisting a mid-conversation `system`/`developer` message into the top-level `system` field no longer carries its `cache_control` marker along, which left the conversation history without a cache breakpoint and forced a full re-read plus a rebuild on the next turn. The boundary is moved to the nearest preceding block that can carry one, and now survives the rewrites that turn `tool_result` and inlined file/document blocks into plain text; if the target block is already marked, both markers are kept unless Anthropic's TTL ordering forbids it. Both hoisting paths are fixed — `extractSystemRoleMessages` and `extractSystemMessagesToBody`. Regression guard: `tests/unit/claude-system-role-cache-boundary.test.ts`. ([#9436](https://github.com/diegosouzapw/OmniRoute/issues/9436))
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(providers):** new per-provider `noAuthFallbackDisabledProviders` setting lets operators disable the synthetic anonymous (no-auth) credential fallback for API-key providers whose static definition declares `anonymousFallback: true` (e.g. `opencode-go`, `opencode-zen`) — upstream endpoints now reject anonymous requests with `401 Missing API key`, so the fallback added latency and caused UI health/reconnect churn. Real keyed connections keep working and recover automatically once quota state clears; true no-auth providers (`opencode`, `mimocode`, …) are unaffected, with `blockedProviders` remaining their disable mechanism. Default behavior is unchanged ([#9675](https://github.com/diegosouzapw/OmniRoute/pull/9675))
|
||||
@@ -7,96 +7,8 @@
|
||||
* chat role, so they must be hoisted. `developer` is OpenAI's Responses-API rename of `system` and
|
||||
* is treated identically. Mutates the payload in place; behaviour is byte-identical to the previous
|
||||
* top-level definition (still re-exported from chatCore.ts for existing importers/tests).
|
||||
*
|
||||
* `relocateHoistedCacheBoundary` keeps that hoist from destroying the client's prompt-cache
|
||||
* layout (#9436); both hoisting implementations share it.
|
||||
*/
|
||||
|
||||
export type HoistedCacheBoundary = "moved" | "kept" | "dropped";
|
||||
|
||||
/** Effective cache TTL of a `cache_control` value; Anthropic defaults to 5m when `ttl` is absent. */
|
||||
function effectiveTtl(marker: unknown): string {
|
||||
const ttl = (marker as Record<string, unknown> | null | undefined)?.ttl;
|
||||
return typeof ttl === "string" ? ttl : "5m";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a content block can carry a cache breakpoint. Excludes blocks Anthropic does not accept
|
||||
* as one (thinking) and blocks the upstream normalisation discards or empties out anyway.
|
||||
*/
|
||||
function isCacheBreakpointTarget(block: unknown): block is Record<string, unknown> {
|
||||
if (block === null || typeof block !== "object") return false;
|
||||
const candidate = block as Record<string, unknown>;
|
||||
switch (candidate.type) {
|
||||
case "text":
|
||||
// Empty text blocks are stripped before the payload goes upstream.
|
||||
return typeof candidate.text === "string" && candidate.text.length > 0;
|
||||
case "tool_use":
|
||||
case "image":
|
||||
case "image_url":
|
||||
case "file":
|
||||
case "file_url":
|
||||
case "document":
|
||||
return true;
|
||||
case "tool_result": {
|
||||
// A tool_result that yields no text collapses to nothing during normalisation.
|
||||
const payload = candidate.content ?? candidate.text ?? candidate.output;
|
||||
if (typeof payload === "string") return payload.length > 0;
|
||||
if (Array.isArray(payload)) {
|
||||
// Only the non-empty text parts of the array survive; images and unknown parts do not.
|
||||
return payload.some((part) => {
|
||||
const text = (part as Record<string, unknown> | null)?.text;
|
||||
return (
|
||||
(part as Record<string, unknown> | null)?.type === "text" &&
|
||||
typeof text === "string" &&
|
||||
text.length > 0
|
||||
);
|
||||
});
|
||||
}
|
||||
return payload != null;
|
||||
}
|
||||
default:
|
||||
// thinking, redacted_thinking, and anything unrecognised.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserves a message-level cache boundary when a marked system/developer block is hoisted into
|
||||
* top-level `system[]`.
|
||||
*
|
||||
* The marker is moved to the nearest preceding block that can carry a breakpoint. If that block is
|
||||
* already marked, both are kept — except where the hoisted marker, which ends up ahead of the
|
||||
* target in `system[]`, would put a 5m breakpoint before a 1h one; Anthropic requires the longer
|
||||
* TTL first, so the hoisted marker is dropped instead.
|
||||
*
|
||||
* @returns `"moved"` or `"dropped"` — the caller must remove the marker from the hoisted block;
|
||||
* `"kept"` — the marker stays on it
|
||||
*/
|
||||
export function relocateHoistedCacheBoundary(
|
||||
marker: unknown,
|
||||
preceding: ReadonlyArray<{ content?: unknown }>
|
||||
): HoistedCacheBoundary {
|
||||
for (let i = preceding.length - 1; i >= 0; i--) {
|
||||
const content = preceding[i]?.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (let j = content.length - 1; j >= 0; j--) {
|
||||
const block = content[j];
|
||||
if (!isCacheBreakpointTarget(block)) continue;
|
||||
if (block.cache_control == null) {
|
||||
block.cache_control = marker;
|
||||
return "moved";
|
||||
}
|
||||
// Occupied: overwriting would discard the client's own marker, and stepping further back
|
||||
// would only shorten the prefix — so both stay, unless the TTL order forbids it.
|
||||
return effectiveTtl(marker) === "5m" && effectiveTtl(block.cache_control) === "1h"
|
||||
? "dropped"
|
||||
: "kept";
|
||||
}
|
||||
}
|
||||
return "kept";
|
||||
}
|
||||
|
||||
export function extractSystemRoleMessages(payload: Record<string, unknown>): void {
|
||||
if (!Array.isArray(payload.messages)) return;
|
||||
const messages = payload.messages as Array<{ role?: unknown; content?: unknown }>;
|
||||
@@ -111,27 +23,13 @@ export function extractSystemRoleMessages(payload: Record<string, unknown>): voi
|
||||
if (systemMessages.length === 0) return;
|
||||
|
||||
const extraBlocks: Array<Record<string, unknown>> = [];
|
||||
// Walk in order rather than over the filtered list: re-anchoring a hoisted `cache_control`
|
||||
// needs the messages that precede it and stay behind (#9436).
|
||||
const preceding: Array<{ content?: unknown }> = [];
|
||||
for (const sm of messages) {
|
||||
if (!isSystemRole(sm.role)) {
|
||||
preceding.push(sm);
|
||||
continue;
|
||||
}
|
||||
for (const sm of systemMessages) {
|
||||
if (typeof sm.content === "string" && sm.content.length > 0) {
|
||||
extraBlocks.push({ type: "text", text: sm.content });
|
||||
} else if (Array.isArray(sm.content)) {
|
||||
for (const block of sm.content as Array<Record<string, unknown>>) {
|
||||
if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) {
|
||||
const hoisted = { ...block };
|
||||
if (
|
||||
hoisted.cache_control != null &&
|
||||
relocateHoistedCacheBoundary(hoisted.cache_control, preceding) !== "kept"
|
||||
) {
|
||||
delete hoisted.cache_control;
|
||||
}
|
||||
extraBlocks.push(hoisted);
|
||||
extraBlocks.push({ ...block });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,58 +12,27 @@
|
||||
*/
|
||||
|
||||
import type { ClaudeContentBlock, ClaudeMessage } from "./claudeMessageTypes.ts";
|
||||
import { extractSystemRoleMessages, relocateHoistedCacheBoundary } from "./claudeSystemRole.ts";
|
||||
import { extractSystemRoleMessages } from "./claudeSystemRole.ts";
|
||||
import { splitMisplacedToolResults } from "../../translator/helpers/claudeHelper.ts";
|
||||
|
||||
type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined;
|
||||
|
||||
/**
|
||||
* Carries a replaced block's `cache_control` onto its substitute. Rewriting a marked block into a
|
||||
* plain text block would otherwise drop the breakpoint the client (or the #9436 hoist) put there.
|
||||
*/
|
||||
function withCacheControl(
|
||||
replacement: ClaudeContentBlock,
|
||||
original: ClaudeContentBlock
|
||||
): ClaudeContentBlock {
|
||||
if (original.cache_control != null) replacement.cache_control = original.cache_control;
|
||||
return replacement;
|
||||
}
|
||||
|
||||
export function extractSystemMessagesToBody(payload: Record<string, unknown>) {
|
||||
if (!Array.isArray(payload.messages)) return;
|
||||
const messages = payload.messages as ClaudeMessage[];
|
||||
const isSystemRole = (role: unknown): boolean => {
|
||||
const normalized = String(role || "").toLowerCase();
|
||||
return normalized === "system" || normalized === "developer";
|
||||
};
|
||||
const systemMessages = messages.filter((m) => isSystemRole(m.role));
|
||||
const systemMessages = messages.filter((m) => {
|
||||
const role = String(m.role || "").toLowerCase();
|
||||
return role === "system" || role === "developer";
|
||||
});
|
||||
if (systemMessages.length === 0) return;
|
||||
const extraBlocks: ClaudeContentBlock[] = [];
|
||||
// Same in-order walk as extractSystemRoleMessages: re-anchoring a hoisted `cache_control`
|
||||
// needs the messages that precede it and stay behind (#9436).
|
||||
const preceding: ClaudeMessage[] = [];
|
||||
for (const sm of messages) {
|
||||
if (!isSystemRole(sm.role)) {
|
||||
preceding.push(sm);
|
||||
continue;
|
||||
}
|
||||
for (const sm of systemMessages) {
|
||||
if (typeof sm.content === "string" && sm.content.length > 0) {
|
||||
extraBlocks.push({ type: "text", text: sm.content });
|
||||
} else if (Array.isArray(sm.content)) {
|
||||
for (const block of sm.content as ClaudeContentBlock[]) {
|
||||
if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) {
|
||||
// Blocks are pushed by reference here (the sibling implementation spreads them), so
|
||||
// only a block whose marker actually moves is copied.
|
||||
if (
|
||||
block.cache_control != null &&
|
||||
relocateHoistedCacheBoundary(block.cache_control, preceding) !== "kept"
|
||||
) {
|
||||
const withoutMarker: ClaudeContentBlock = { ...block };
|
||||
delete withoutMarker.cache_control;
|
||||
extraBlocks.push(withoutMarker);
|
||||
} else {
|
||||
extraBlocks.push(block);
|
||||
}
|
||||
extraBlocks.push(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +47,10 @@ export function extractSystemMessagesToBody(payload: Record<string, unknown>) {
|
||||
payload.system = extraBlocks;
|
||||
}
|
||||
}
|
||||
payload.messages = messages.filter((m) => !isSystemRole(m.role));
|
||||
payload.messages = messages.filter((m) => {
|
||||
const role = String(m.role || "").toLowerCase();
|
||||
return role !== "system" && role !== "developer";
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeClaudeUpstreamMessages(
|
||||
@@ -132,7 +104,7 @@ export function normalizeClaudeUpstreamMessages(
|
||||
const fileName =
|
||||
(block.file as Record<string, unknown>)?.name ?? block.name ?? "attachment";
|
||||
if (typeof fileContent === "string" && fileContent.length > 0) {
|
||||
return [withCacheControl({ type: "text", text: `[${fileName}]\n${fileContent}` }, block)];
|
||||
return [{ type: "text", text: `[${fileName}]\n${fileContent}` }];
|
||||
}
|
||||
}
|
||||
return [block];
|
||||
@@ -154,9 +126,7 @@ export function normalizeClaudeUpstreamMessages(
|
||||
.join("\n")
|
||||
: JSON.stringify(resultContent);
|
||||
if (resultText.length > 0) {
|
||||
return [
|
||||
withCacheControl({ type: "text", text: `[Tool Result: ${toolId}]\n${resultText}` }, block),
|
||||
];
|
||||
return [{ type: "text", text: `[Tool Result: ${toolId}]\n${resultText}` }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ import EmptyConnectionsPlaceholder from "./components/EmptyConnectionsPlaceholde
|
||||
import UpstreamProxyCard from "./components/UpstreamProxyCard";
|
||||
import SearchProviderCard from "./components/SearchProviderCard";
|
||||
import NoAuthProviderControls from "./components/NoAuthProviderControls";
|
||||
import AnonymousFallbackToggle from "./components/AnonymousFallbackToggle";
|
||||
// providerText used by UpstreamProxyCard (Phase 1t.7)
|
||||
|
||||
export default function ProviderDetailPageClient() {
|
||||
@@ -538,6 +539,12 @@ export default function ProviderDetailPageClient() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isUpstreamProxyProvider && !isFreeNoAuth && (
|
||||
<AnonymousFallbackToggle
|
||||
providerId={providerId}
|
||||
providerName={providerInfo?.name || providerId}
|
||||
/>
|
||||
)}
|
||||
{!isUpstreamProxyProvider && !isFreeNoAuth && (
|
||||
<Card>
|
||||
<ProviderAccountRoutingCard
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
computeNoAuthFallbackDisabledProviders,
|
||||
isNoAuthFallbackEnabled,
|
||||
} from "../components/AnonymousFallbackToggle";
|
||||
|
||||
describe("AnonymousFallbackToggle list-update helpers", () => {
|
||||
it("disabling adds the providerId exactly once and dedupes existing entries", () => {
|
||||
const next = computeNoAuthFallbackDisabledProviders(
|
||||
["openai", "openai", "opencode-go"],
|
||||
"opencode-go",
|
||||
"opencode",
|
||||
true
|
||||
);
|
||||
expect(next).toEqual(["openai", "opencode-go"]);
|
||||
expect(next.filter((id) => id === "opencode-go")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("enabling removes both the providerId and its alias", () => {
|
||||
const next = computeNoAuthFallbackDisabledProviders(
|
||||
["openai", "opencode-go", "opencode"],
|
||||
"opencode-go",
|
||||
"opencode",
|
||||
false
|
||||
);
|
||||
expect(next).toEqual(["openai"]);
|
||||
});
|
||||
|
||||
it("enabling with only the alias present also removes it", () => {
|
||||
const next = computeNoAuthFallbackDisabledProviders(
|
||||
["opencode"],
|
||||
"opencode-go",
|
||||
"opencode",
|
||||
false
|
||||
);
|
||||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it("is enabled by default when the disabled list is absent", () => {
|
||||
expect(isNoAuthFallbackEnabled("opencode-go", "opencode", undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("is disabled when the providerId is in the list", () => {
|
||||
expect(isNoAuthFallbackEnabled("opencode-go", "opencode", ["opencode-go"])).toBe(false);
|
||||
});
|
||||
|
||||
it("is disabled when only the alias is in the list", () => {
|
||||
expect(isNoAuthFallbackEnabled("opencode-go", "opencode", ["opencode"])).toBe(false);
|
||||
});
|
||||
|
||||
it("is enabled when the list is present but does not contain the provider", () => {
|
||||
expect(isNoAuthFallbackEnabled("opencode-go", "opencode", ["openai"])).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
// Issue #8935 — per-provider opt-out for the synthetic anonymous (no-auth)
|
||||
// credential fallback on API-key providers whose static definition declares
|
||||
// anonymousFallback: true (opencode-go, opencode-zen, pollinations, kilocode).
|
||||
// Default ON (fallback enabled) when the setting is absent, so existing
|
||||
// behavior is preserved for everyone who does not opt out. True no-auth
|
||||
// providers (NOAUTH_PROVIDERS / WEB_COOKIE_PROVIDERS) never see this control —
|
||||
// their synthetic credential is the only credential path and is governed by
|
||||
// blockedProviders instead.
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/shared/components";
|
||||
import { getProviderAlias, getProviderById } from "@/shared/constants/providers";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { providerText } from "../providerPageHelpers";
|
||||
|
||||
export function computeNoAuthFallbackDisabledProviders(
|
||||
current: string[],
|
||||
providerId: string,
|
||||
providerAlias: string | undefined,
|
||||
disabling: boolean
|
||||
): string[] {
|
||||
const keysToRemove = new Set([providerId, providerAlias].filter(Boolean));
|
||||
if (!disabling) {
|
||||
return current.filter((item) => !keysToRemove.has(item));
|
||||
}
|
||||
return Array.from(new Set([...current.filter((item) => !keysToRemove.has(item)), providerId]));
|
||||
}
|
||||
|
||||
export function isNoAuthFallbackEnabled(
|
||||
providerId: string,
|
||||
providerAlias: string | undefined,
|
||||
disabledProviders: string[] | undefined
|
||||
): boolean {
|
||||
if (!Array.isArray(disabledProviders)) return true;
|
||||
return (
|
||||
!disabledProviders.includes(providerId) &&
|
||||
!(typeof providerAlias === "string" && disabledProviders.includes(providerAlias))
|
||||
);
|
||||
}
|
||||
|
||||
interface AnonymousFallbackToggleProps {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
export default function AnonymousFallbackToggle({
|
||||
providerId,
|
||||
providerName,
|
||||
}: AnonymousFallbackToggleProps) {
|
||||
const t = useTranslations("providers");
|
||||
const notify = useNotificationStore();
|
||||
const [disabledProviders, setDisabledProviders] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const providerDef = getProviderById(providerId) as { anonymousFallback?: boolean } | undefined;
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
const fallbackEnabled = isNoAuthFallbackEnabled(providerId, providerAlias, disabledProviders);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function fetchDisabledProviders() {
|
||||
try {
|
||||
const response = await fetch("/api/settings", { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (!cancelled && Array.isArray(data.noAuthFallbackDisabledProviders)) {
|
||||
setDisabledProviders(data.noAuthFallbackDisabledProviders);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch provider settings:", error);
|
||||
}
|
||||
}
|
||||
|
||||
void fetchDisabledProviders();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (nextEnabled: boolean) => {
|
||||
const previous = disabledProviders;
|
||||
const next = computeNoAuthFallbackDisabledProviders(
|
||||
previous,
|
||||
providerId,
|
||||
providerAlias,
|
||||
!nextEnabled
|
||||
);
|
||||
setDisabledProviders(next);
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ noAuthFallbackDisabledProviders: next }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
data?.error?.message ||
|
||||
data?.error ||
|
||||
providerText(
|
||||
t,
|
||||
"anonymousFallbackUpdateFailed",
|
||||
"Failed to update anonymous fallback setting"
|
||||
)
|
||||
);
|
||||
}
|
||||
setDisabledProviders(
|
||||
Array.isArray(data.noAuthFallbackDisabledProviders)
|
||||
? data.noAuthFallbackDisabledProviders
|
||||
: next
|
||||
);
|
||||
notify.success(
|
||||
nextEnabled
|
||||
? providerText(
|
||||
t,
|
||||
"anonymousFallbackEnabled",
|
||||
"Anonymous fallback enabled for {provider}",
|
||||
{
|
||||
provider: providerName,
|
||||
}
|
||||
)
|
||||
: providerText(
|
||||
t,
|
||||
"anonymousFallbackDisabled",
|
||||
"Anonymous fallback disabled for {provider} — exhausted connections will skip this provider",
|
||||
{ provider: providerName }
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
setDisabledProviders(previous);
|
||||
notify.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: providerText(
|
||||
t,
|
||||
"anonymousFallbackUpdateFailed",
|
||||
"Failed to update anonymous fallback setting"
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[disabledProviders, notify, providerAlias, providerId, providerName, t]
|
||||
);
|
||||
|
||||
// Only API-key providers whose static definition opts into the anonymous
|
||||
// fallback get this control; everything else self-hides.
|
||||
if (providerDef?.anonymousFallback !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = providerText(t, "anonymousFallbackTitle", "Anonymous fallback");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex shrink-0 items-center justify-center w-10 h-10 rounded-full bg-sky-500/10 text-sky-500">
|
||||
<span className="material-symbols-outlined text-[20px]">key_off</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{providerText(
|
||||
t,
|
||||
"anonymousFallbackDesc",
|
||||
"When all configured connections are exhausted (quota, credits, or expiry), temporarily use this provider's keyless tier. Turn off to skip this provider instead of sending anonymous requests — recommended when the keyless tier rejects them (401)."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={fallbackEnabled}
|
||||
aria-label={title}
|
||||
disabled={saving}
|
||||
onClick={() => handleToggle(!fallbackEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
fallbackEnabled ? "bg-sky-500" : "bg-black/[0.12] dark:bg-white/[0.15]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||
fallbackEnabled ? "translate-x-[26px]" : "translate-x-[3px]"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -5027,6 +5027,11 @@
|
||||
"hideFailedAuto": "Auto-hide failed models",
|
||||
"selectAllModels": "Select all",
|
||||
"hideAllModels": "Hide all",
|
||||
"anonymousFallbackTitle": "Anonymous fallback",
|
||||
"anonymousFallbackDesc": "When all configured connections are exhausted (quota, credits, or expiry), temporarily use this provider's keyless tier. Turn off to skip this provider instead of sending anonymous requests — recommended when the keyless tier rejects them (401).",
|
||||
"anonymousFallbackEnabled": "Anonymous fallback enabled for {provider}",
|
||||
"anonymousFallbackDisabled": "Anonymous fallback disabled for {provider} — exhausted connections will skip this provider",
|
||||
"anonymousFallbackUpdateFailed": "Failed to update anonymous fallback setting",
|
||||
"modelsActive": "Active",
|
||||
"showModel": "Show model",
|
||||
"hideModel": "Hide model",
|
||||
|
||||
@@ -118,6 +118,7 @@ export const updateSettingsSchema = z.object({
|
||||
baseUrl: z.string().max(500).optional(),
|
||||
setupComplete: z.boolean().optional(),
|
||||
blockedProviders: z.array(z.string().max(100)).optional(),
|
||||
noAuthFallbackDisabledProviders: z.array(z.string().max(100)).optional(),
|
||||
hidePaidModels: z.boolean().optional(),
|
||||
hideHealthCheckLogs: z.boolean().optional(),
|
||||
hideEndpointCloudflaredTunnel: z.boolean().optional(),
|
||||
|
||||
@@ -76,7 +76,10 @@ import {
|
||||
resolveSessionAffinityTtlMs,
|
||||
selectSessionAffinityConnection,
|
||||
} from "./sessionAffinityPin";
|
||||
import { isNoAuthProviderBlockedBySettings } from "./noAuthProviderSettings";
|
||||
import {
|
||||
isAnonymousFallbackDisabledBySettings,
|
||||
isNoAuthProviderBlockedBySettings,
|
||||
} from "./noAuthProviderSettings";
|
||||
import { resolveAccountProxiesFromRegistry } from "./noAuthProxyResolution";
|
||||
import { getNoAuthHydrationProviderIds } from "./noAuthProviderSiblings";
|
||||
import { getResource404Bypass } from "./requestResourceHealth";
|
||||
@@ -773,12 +776,43 @@ function providerCanUseSyntheticNoAuthFallback(providerId: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True only for API-key gateway providers whose synthetic anonymous fallback
|
||||
* eligibility comes from `anonymousFallback: true` on the static definition —
|
||||
* NOT for true no-auth providers (NOAUTH_PROVIDERS / WEB_COOKIE_PROVIDERS),
|
||||
* where the synthetic credential is the only credential path (blockedProviders
|
||||
* is the disable mechanism for those). `noAuthFallbackDisabledProviders` gates
|
||||
* exactly this subset.
|
||||
*/
|
||||
function isAnonymousFallbackOnlyProvider(providerId: string): boolean {
|
||||
const providerDef = getProviderById(providerId) as
|
||||
AnonymousFallbackProviderDefinition | undefined;
|
||||
const noAuthProviderDef = (
|
||||
NOAUTH_PROVIDERS as Record<string, AnonymousFallbackProviderDefinition | undefined>
|
||||
)[providerId];
|
||||
const webCookieProviderDef = (
|
||||
WEB_COOKIE_PROVIDERS as Record<string, AnonymousFallbackProviderDefinition | undefined>
|
||||
)[providerId];
|
||||
return (
|
||||
providerDef?.anonymousFallback === true &&
|
||||
noAuthProviderDef?.noAuth !== true &&
|
||||
webCookieProviderDef?.noAuth !== true
|
||||
);
|
||||
}
|
||||
|
||||
async function maybeSyntheticNoAuthFallback(
|
||||
providerId: string,
|
||||
excludedConnectionIds: Set<string>
|
||||
) {
|
||||
if (!providerCanUseSyntheticNoAuthFallback(providerId)) return null;
|
||||
if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) return null;
|
||||
if (
|
||||
isAnonymousFallbackOnlyProvider(providerId) &&
|
||||
(await isAnonymousFallbackDisabledBySettings(providerId))
|
||||
) {
|
||||
log.info("AUTH", `${providerId} | anonymous no-auth fallback disabled by settings`);
|
||||
return null;
|
||||
}
|
||||
// #4954: hydrate per-account proxy/rotation config off the connection row so
|
||||
// no-auth executors (opencode, mimocode) actually honor configured proxies.
|
||||
const providerSpecificData = await loadNoAuthProviderSpecificData(providerId);
|
||||
@@ -2030,7 +2064,10 @@ export async function markAccountUnavailable(
|
||||
? "model"
|
||||
: getQuotaScopeLabelForProvider(provider, model);
|
||||
const antigravityFamilyInferredBaseCooldownMs =
|
||||
!usesExactAntigravityLock && provider === "antigravity" && quotaScope === "family" && status === 429
|
||||
!usesExactAntigravityLock &&
|
||||
provider === "antigravity" &&
|
||||
quotaScope === "family" &&
|
||||
status === 429
|
||||
? ANTIGRAVITY_FAMILY_INFERRED_BASE_COOLDOWN_MS
|
||||
: null;
|
||||
const lockout = recordModelLockoutFailure(
|
||||
|
||||
@@ -16,3 +16,30 @@ export async function isNoAuthProviderBlockedBySettings(providerId: string): Pro
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-provider opt-out for the synthetic anonymous (no-auth) credential
|
||||
* fallback. Only applies to API-key gateway providers whose fallback
|
||||
* eligibility comes from `anonymousFallback: true` on the static provider
|
||||
* definition (e.g. opencode-go, opencode-zen). True no-auth providers
|
||||
* (NOAUTH_PROVIDERS / WEB_COOKIE_PROVIDERS) are NOT matched here — for them
|
||||
* the synthetic credential is the only credential path, and `blockedProviders`
|
||||
* remains the disable mechanism.
|
||||
*
|
||||
* Fail-open: any settings-read error returns false, preserving current
|
||||
* behavior (anonymous fallback keeps working).
|
||||
*/
|
||||
export async function isAnonymousFallbackDisabledBySettings(providerId: string): Promise<boolean> {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
return isProviderBlockedByIdOrAlias(providerId, settings.noAuthFallbackDisabledProviders);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
"AUTH",
|
||||
`Could not read no-auth fallback disabled settings for ${providerId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
181
tests/unit/auth-anonymous-fallback-toggle.test.ts
Normal file
181
tests/unit/auth-anonymous-fallback-toggle.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Per-provider opt-out for the synthetic anonymous (no-auth) credential
|
||||
* fallback (`noAuthFallbackDisabledProviders`).
|
||||
*
|
||||
* API-key gateway providers whose static definition declares
|
||||
* `anonymousFallback: true` (opencode-go, opencode-zen, pollinations, …) get a
|
||||
* synthetic "noauth" connection whenever all real configured connections are
|
||||
* terminal (expired/banned/credits_exhausted) or all unavailable. Upstream
|
||||
* endpoints now reject anonymous requests with 401 Missing API key, so operators
|
||||
* need a per-provider toggle to disable that fallback while keeping the provider
|
||||
* enabled and real keyed connections working.
|
||||
*
|
||||
* The gate applies ONLY to `anonymousFallback: true` API-key providers. True
|
||||
* no-auth providers (NOAUTH_PROVIDERS / WEB_COOKIE_PROVIDERS entries with
|
||||
* `noAuth: true`, e.g. opencode, mimocode) are NOT affected — for them the
|
||||
* synthetic credential is the only credential path and `blockedProviders` is
|
||||
* the disable mechanism.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-anon-fallback-toggle-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { getProviderCredentials } = await import("../../src/sse/services/auth.ts");
|
||||
const { createProviderConnection, updateProviderConnection, deleteProviderConnectionsByProvider } =
|
||||
await import("../../src/lib/db/providers.ts");
|
||||
const { updateSettings } = await import("../../src/lib/db/settings.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Set the opt-out list; pass null to remove the key entirely (absent setting). */
|
||||
async function setNoAuthFallbackDisabledProviders(providers: string[] | null): Promise<void> {
|
||||
if (providers === null) {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
"DELETE FROM key_value WHERE namespace = 'settings' AND key = 'noAuthFallbackDisabledProviders'"
|
||||
).run();
|
||||
return;
|
||||
}
|
||||
await updateSettings({ noAuthFallbackDisabledProviders: providers });
|
||||
}
|
||||
|
||||
function assertSyntheticNoAuth(creds: unknown, providerId: string): void {
|
||||
assert.ok(creds, `${providerId} must resolve to synthetic no-auth credentials`);
|
||||
assert.equal(
|
||||
(creds as { connectionId?: string }).connectionId,
|
||||
"noauth",
|
||||
`${providerId} should return the synthetic "noauth" connection`
|
||||
);
|
||||
assert.equal((creds as { apiKey?: unknown }).apiKey, null, "anonymous access carries no api key");
|
||||
}
|
||||
|
||||
test("a. backward compat default: no setting → terminal opencode-go falls back to noauth", async () => {
|
||||
await setNoAuthFallbackDisabledProviders(null);
|
||||
await deleteProviderConnectionsByProvider("opencode-go");
|
||||
await createProviderConnection({
|
||||
provider: "opencode-go",
|
||||
authType: "apikey",
|
||||
name: "expired-key-default",
|
||||
apiKey: "sk-expired-default",
|
||||
isActive: false,
|
||||
testStatus: "expired",
|
||||
});
|
||||
|
||||
const creds = await getProviderCredentials("opencode-go");
|
||||
assertSyntheticNoAuth(creds, "opencode-go");
|
||||
});
|
||||
|
||||
test("b. disabled + all terminal → allExpired result, never noauth", async () => {
|
||||
await setNoAuthFallbackDisabledProviders(["opencode-go"]);
|
||||
await deleteProviderConnectionsByProvider("opencode-go");
|
||||
await createProviderConnection({
|
||||
provider: "opencode-go",
|
||||
authType: "apikey",
|
||||
name: "expired-key-disabled",
|
||||
apiKey: "sk-expired-disabled",
|
||||
isActive: false,
|
||||
testStatus: "expired",
|
||||
});
|
||||
|
||||
const result = (await getProviderCredentials("opencode-go")) as Record<string, unknown> | null;
|
||||
assert.ok(result, "must return a structured result (allExpired), not null");
|
||||
assert.equal(result.allExpired, true, "terminal connections should surface as allExpired");
|
||||
assert.notEqual(
|
||||
result.connectionId,
|
||||
"noauth",
|
||||
"disabled provider must never receive synthetic no-auth credentials"
|
||||
);
|
||||
});
|
||||
|
||||
test("c. disabled + zero connections → null, never noauth", async () => {
|
||||
await setNoAuthFallbackDisabledProviders(["opencode-go", "opencode-zen"]);
|
||||
await deleteProviderConnectionsByProvider("opencode-zen");
|
||||
|
||||
const result = await getProviderCredentials("opencode-zen");
|
||||
assert.equal(result, null, "disabled provider with no connections must not fall back to noauth");
|
||||
});
|
||||
|
||||
test("d. disabled + healthy real connection → real credentials still selected", async () => {
|
||||
await setNoAuthFallbackDisabledProviders(["opencode-go"]);
|
||||
await deleteProviderConnectionsByProvider("opencode-go");
|
||||
const created = await createProviderConnection({
|
||||
provider: "opencode-go",
|
||||
authType: "apikey",
|
||||
name: "healthy-key",
|
||||
apiKey: "sk-opencode-go-live",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
|
||||
const result = (await getProviderCredentials("opencode-go")) as Record<string, unknown> | null;
|
||||
assert.ok(result, "healthy keyed connection must still resolve to credentials");
|
||||
assert.equal(result.connectionId, created.id, "must select the real DB connection");
|
||||
assert.equal(result.apiKey, "sk-opencode-go-live", "real api key must be returned");
|
||||
assert.notEqual(result.connectionId, "noauth");
|
||||
});
|
||||
|
||||
test("e. recovery: rate-limited → allRateLimited; quota recovered → real connection again", async () => {
|
||||
await setNoAuthFallbackDisabledProviders(["opencode-go"]);
|
||||
await deleteProviderConnectionsByProvider("opencode-go");
|
||||
const created = await createProviderConnection({
|
||||
provider: "opencode-go",
|
||||
authType: "apikey",
|
||||
name: "recovering-key",
|
||||
apiKey: "sk-opencode-go-recover",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
rateLimitedUntil: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
|
||||
const exhausted = (await getProviderCredentials("opencode-go")) as Record<string, unknown> | null;
|
||||
assert.ok(exhausted, "rate-limited connection must produce a structured result");
|
||||
assert.equal(
|
||||
exhausted.allRateLimited,
|
||||
true,
|
||||
"while rate limited the provider should surface allRateLimited, not noauth"
|
||||
);
|
||||
assert.notEqual(exhausted.connectionId, "noauth");
|
||||
|
||||
await updateProviderConnection(created.id as string, { rateLimitedUntil: null });
|
||||
|
||||
const recovered = (await getProviderCredentials("opencode-go")) as Record<string, unknown> | null;
|
||||
assert.ok(recovered, "recovered connection must resolve to credentials again");
|
||||
assert.equal(
|
||||
recovered.connectionId,
|
||||
created.id,
|
||||
"once quota recovers the real connection must be selected again"
|
||||
);
|
||||
assert.equal(recovered.apiKey, "sk-opencode-go-recover");
|
||||
});
|
||||
|
||||
test("f. true no-auth provider unaffected: opencode still returns synthetic noauth", async () => {
|
||||
await setNoAuthFallbackDisabledProviders(["opencode", "opencode-go", "opencode-zen"]);
|
||||
|
||||
const creds = await getProviderCredentials("opencode");
|
||||
assertSyntheticNoAuth(creds, "opencode");
|
||||
});
|
||||
|
||||
test("g. re-enable: removing provider from the list restores the fallback", async () => {
|
||||
await setNoAuthFallbackDisabledProviders(["opencode-zen"]);
|
||||
await deleteProviderConnectionsByProvider("opencode-go");
|
||||
await createProviderConnection({
|
||||
provider: "opencode-go",
|
||||
authType: "apikey",
|
||||
name: "expired-key-reenabled",
|
||||
apiKey: "sk-expired-reenabled",
|
||||
isActive: false,
|
||||
testStatus: "expired",
|
||||
});
|
||||
|
||||
const creds = await getProviderCredentials("opencode-go");
|
||||
assertSyntheticNoAuth(creds, "opencode-go");
|
||||
});
|
||||
@@ -1,409 +0,0 @@
|
||||
// tests/unit/claude-system-role-cache-boundary.test.ts
|
||||
// Regression coverage for #9436.
|
||||
// Hoisting a marked system/developer block must not remove the effective cache boundary from
|
||||
// messages[]. Covers both hoisting implementations plus the native Claude path.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { extractSystemRoleMessages } from "../../open-sse/handlers/chatCore/claudeSystemRole.ts";
|
||||
import {
|
||||
extractSystemMessagesToBody,
|
||||
normalizeClaudeUpstreamMessages,
|
||||
} from "../../open-sse/handlers/chatCore/claudeUpstreamMessages.ts";
|
||||
|
||||
/** Breakpoint layout in the same terms the incident telemetry reports it: sys=N, msg=N. */
|
||||
function layout(payload: Record<string, unknown>): { sys: number; msg: number } {
|
||||
const marked = (blocks: unknown): number =>
|
||||
Array.isArray(blocks)
|
||||
? blocks.filter(
|
||||
(b) => b !== null && typeof b === "object" && (b as Record<string, unknown>).cache_control != null
|
||||
).length
|
||||
: 0;
|
||||
const messages = (Array.isArray(payload.messages) ? payload.messages : []) as Array<{
|
||||
content?: unknown;
|
||||
}>;
|
||||
return {
|
||||
sys: marked(payload.system),
|
||||
msg: messages.reduce((n, m) => n + marked(m.content), 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A Claude Code shaped turn: a cached system prompt, an accumulated mid-conversation system note,
|
||||
* and the second breakpoint the client advances each round. `markerOn` selects where that second
|
||||
* breakpoint sits — on the system note (the reported defect) or on the newest tool_result (what the
|
||||
* healthy calls in the incident window show).
|
||||
*/
|
||||
function claudeCodeTurn(markerOn: "systemNote" | "toolResult"): Record<string, unknown> {
|
||||
const note: Record<string, unknown> = { type: "text", text: "mid-conversation system note" };
|
||||
const toolResult: Record<string, unknown> = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
content: "ok",
|
||||
};
|
||||
(markerOn === "systemNote" ? note : toolResult).cache_control = { type: "ephemeral" };
|
||||
|
||||
return {
|
||||
system: [{ type: "text", text: "base system prompt", cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "turn 1" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "reply 1" }] },
|
||||
{ role: "system", content: [note] },
|
||||
{ role: "user", content: [toolResult] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test("#9436 hoisting a marked system note keeps a cache boundary inside messages", () => {
|
||||
const payload = claudeCodeTurn("systemNote");
|
||||
assert.deepEqual(layout(payload), { sys: 1, msg: 1 });
|
||||
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
// Before the fix this was { sys: 2, msg: 0 } — the production signature behind 99.4 % of all
|
||||
// uncached input tokens in the incident window.
|
||||
assert.deepEqual(layout(payload), { sys: 1, msg: 1 });
|
||||
});
|
||||
|
||||
test("#9436 the boundary lands before the hoisted note, never after it", () => {
|
||||
const payload = claudeCodeTurn("systemNote");
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
const messages = payload.messages as Array<{ content: Array<Record<string, unknown>> }>;
|
||||
assert.equal(messages.length, 3);
|
||||
// The assistant reply directly preceded the system note — same cut point in the reordered prefix.
|
||||
assert.deepEqual(messages[1].content[0].cache_control, { type: "ephemeral" });
|
||||
// Moving it onto the newer tool_result would cache content the client never marked.
|
||||
assert.equal(messages[2].content[0].cache_control, undefined);
|
||||
});
|
||||
|
||||
test("#9436 the marker does not ride along into the system array", () => {
|
||||
const payload = claudeCodeTurn("systemNote");
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
const system = payload.system as Array<Record<string, unknown>>;
|
||||
assert.equal(system.length, 2); // merged, not dropped — #7293
|
||||
assert.equal(system[1].text, "mid-conversation system note");
|
||||
assert.equal(system[1].cache_control, undefined);
|
||||
});
|
||||
|
||||
test("hoisting still lifts system/developer content out of messages (#7293)", () => {
|
||||
const payload = claudeCodeTurn("toolResult");
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
const messages = payload.messages as Array<{ role: string }>;
|
||||
assert.ok(!messages.some((m) => m.role === "system"));
|
||||
assert.equal((payload.system as unknown[]).length, 2);
|
||||
});
|
||||
|
||||
test("a healthy cache-boundary layout remains unchanged", () => {
|
||||
const payload = claudeCodeTurn("toolResult");
|
||||
assert.deepEqual(layout(payload), { sys: 1, msg: 1 });
|
||||
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
assert.deepEqual(layout(payload), { sys: 1, msg: 1 });
|
||||
const messages = payload.messages as Array<{ content: Array<Record<string, unknown>> }>;
|
||||
assert.deepEqual(messages[2].content[0].cache_control, { type: "ephemeral" }); // still the tool_result
|
||||
assert.equal(messages[1].content[0].cache_control, undefined); // no marker added
|
||||
});
|
||||
|
||||
/** A turn whose re-anchor target is already marked, so the two TTLs decide the outcome. */
|
||||
function occupiedTarget(targetTtl: string, hoistedTtl: string): Record<string, unknown> {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "turn 1", cache_control: { type: "ephemeral", ttl: targetTtl } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{ type: "text", text: "note", cache_control: { type: "ephemeral", ttl: hoistedTtl } },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "turn 2" }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads back both markers of an `occupiedTarget` payload after hoisting. */
|
||||
function markers(payload: Record<string, unknown>): { target: unknown; hoisted: unknown } {
|
||||
const messages = payload.messages as Array<{ content: Array<Record<string, unknown>> }>;
|
||||
const system = payload.system as Array<Record<string, unknown>>;
|
||||
return { target: messages[0].content[0].cache_control, hoisted: system[0].cache_control };
|
||||
}
|
||||
|
||||
test("occupied 1h target, hoisted 5m marker: only the target marker survives", () => {
|
||||
// Keeping both would leave a 5m breakpoint in system[] ahead of the 1h one in messages[];
|
||||
// Anthropic requires the longer TTL first.
|
||||
const payload = occupiedTarget("1h", "5m");
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
assert.deepEqual(markers(payload).target, { type: "ephemeral", ttl: "1h" });
|
||||
assert.equal(markers(payload).hoisted, undefined);
|
||||
assert.deepEqual(layout(payload), { sys: 0, msg: 1 });
|
||||
});
|
||||
|
||||
test("occupied 5m target, hoisted 1h marker: both markers survive", () => {
|
||||
const payload = occupiedTarget("5m", "1h");
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
assert.deepEqual(markers(payload).target, { type: "ephemeral", ttl: "5m" });
|
||||
assert.deepEqual(markers(payload).hoisted, { type: "ephemeral", ttl: "1h" });
|
||||
assert.deepEqual(layout(payload), { sys: 1, msg: 1 });
|
||||
});
|
||||
|
||||
test("occupied target, both markers 5m: both survive", () => {
|
||||
const payload = occupiedTarget("5m", "5m");
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
assert.deepEqual(markers(payload).target, { type: "ephemeral", ttl: "5m" });
|
||||
assert.deepEqual(markers(payload).hoisted, { type: "ephemeral", ttl: "5m" });
|
||||
assert.deepEqual(layout(payload), { sys: 1, msg: 1 });
|
||||
});
|
||||
|
||||
test("occupied target, both markers 1h: both survive", () => {
|
||||
const payload = occupiedTarget("1h", "1h");
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
assert.deepEqual(markers(payload).target, { type: "ephemeral", ttl: "1h" });
|
||||
assert.deepEqual(markers(payload).hoisted, { type: "ephemeral", ttl: "1h" });
|
||||
assert.deepEqual(layout(payload), { sys: 1, msg: 1 });
|
||||
});
|
||||
|
||||
test("two consecutive marked system messages compete for the same target", () => {
|
||||
// No message between them, so both would re-anchor onto the same block. The first takes it;
|
||||
// the second keeps its marker rather than overwriting the first.
|
||||
const payload: Record<string, unknown> = {
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "turn 1" }] },
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "note a", cache_control: { type: "ephemeral", ttl: "5m" } }],
|
||||
},
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "note b", cache_control: { type: "ephemeral", ttl: "1h" } }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "turn 2" }] },
|
||||
],
|
||||
};
|
||||
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
const messages = payload.messages as Array<{ content: Array<Record<string, unknown>> }>;
|
||||
const system = payload.system as Array<Record<string, unknown>>;
|
||||
assert.deepEqual(messages[0].content[0].cache_control, { type: "ephemeral", ttl: "5m" });
|
||||
assert.equal(system[0].cache_control, undefined); // note a — moved
|
||||
assert.deepEqual(system[1].cache_control, { type: "ephemeral", ttl: "1h" }); // note b — kept
|
||||
assert.deepEqual(layout(payload), { sys: 1, msg: 1 }); // two in, two out
|
||||
});
|
||||
|
||||
test("several marked system messages keep one boundary each, without adding any", () => {
|
||||
// The `sys=3,msg=0` shape from the incident window: more than one hoisted block carries a marker.
|
||||
const payload: Record<string, unknown> = {
|
||||
system: [{ type: "text", text: "base", cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "turn 1" }] },
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "note a", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "text", text: "reply 1" }] },
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "note b", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "turn 2" }] },
|
||||
],
|
||||
};
|
||||
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
const after = layout(payload);
|
||||
assert.equal(after.sys, 1); // only the client's own system prompt keeps its marker
|
||||
assert.equal(after.msg, 2); // one boundary per hoisted note, each at its own position
|
||||
// Anthropic caps a request at four breakpoints, so relocation must never inflate the count.
|
||||
assert.equal(after.sys + after.msg, 3);
|
||||
});
|
||||
|
||||
test("with no usable preceding message the marker stays on the hoisted block", () => {
|
||||
// A shorter cached prefix is acceptable; a longer one would cache content the client did not
|
||||
// mark. Nothing precedes the note here, so system[] is the same cut point.
|
||||
const payload: Record<string, unknown> = {
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "note", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "turn 1" }] },
|
||||
],
|
||||
};
|
||||
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
assert.deepEqual(layout(payload), { sys: 1, msg: 0 });
|
||||
const messages = payload.messages as Array<{ content: Array<Record<string, unknown>> }>;
|
||||
assert.equal(messages[0].content[0].cache_control, undefined);
|
||||
});
|
||||
|
||||
test("#9436 also holds for the second hoisting implementation", () => {
|
||||
// extractSystemMessagesToBody is a near-duplicate of extractSystemRoleMessages; a fix that
|
||||
// touches only one of them leaves the other path broken.
|
||||
const payload = claudeCodeTurn("systemNote");
|
||||
extractSystemMessagesToBody(payload);
|
||||
assert.deepEqual(layout(payload), { sys: 1, msg: 1 });
|
||||
});
|
||||
|
||||
test("the second hoisting implementation resolves an occupied target identically", () => {
|
||||
const dropped = occupiedTarget("1h", "5m");
|
||||
extractSystemMessagesToBody(dropped);
|
||||
assert.equal(markers(dropped).hoisted, undefined);
|
||||
assert.deepEqual(layout(dropped), { sys: 0, msg: 1 });
|
||||
|
||||
const kept = occupiedTarget("5m", "1h");
|
||||
extractSystemMessagesToBody(kept);
|
||||
assert.deepEqual(markers(kept).hoisted, { type: "ephemeral", ttl: "1h" });
|
||||
assert.deepEqual(layout(kept), { sys: 1, msg: 1 });
|
||||
});
|
||||
|
||||
test("a thinking block is never chosen as the boundary", () => {
|
||||
const payload: Record<string, unknown> = {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "reply 1" },
|
||||
{ type: "thinking", thinking: "…", signature: "sig" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "note", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "turn 2" }] },
|
||||
],
|
||||
};
|
||||
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
const messages = payload.messages as Array<{ content: Array<Record<string, unknown>> }>;
|
||||
assert.deepEqual(messages[0].content[0].cache_control, { type: "ephemeral" }); // the text block
|
||||
assert.equal(messages[0].content[1].cache_control, undefined); // the thinking block
|
||||
assert.deepEqual(layout(payload), { sys: 0, msg: 1 });
|
||||
});
|
||||
|
||||
test("with only thinking and empty text before it the marker stays on the hoisted block", () => {
|
||||
const payload: Record<string, unknown> = {
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "" }] },
|
||||
{ role: "assistant", content: [{ type: "thinking", thinking: "…", signature: "sig" }] },
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "note", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "turn 2" }] },
|
||||
],
|
||||
};
|
||||
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
assert.deepEqual(layout(payload), { sys: 1, msg: 0 });
|
||||
assert.deepEqual((payload.system as Array<Record<string, unknown>>)[0].cache_control, {
|
||||
type: "ephemeral",
|
||||
});
|
||||
});
|
||||
|
||||
test("a tool_result whose array yields no text is skipped as a target", () => {
|
||||
// Normalisation keeps only the non-empty text parts of a tool_result array, so this block is
|
||||
// dropped entirely — anchoring the boundary on it would lose the marker again.
|
||||
const payload: Record<string, unknown> = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
content: [
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "iVBOR" } },
|
||||
{ type: "text", text: "" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "note", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "turn 2" }] },
|
||||
],
|
||||
};
|
||||
|
||||
extractSystemRoleMessages(payload);
|
||||
|
||||
assert.deepEqual(layout(payload), { sys: 1, msg: 0 });
|
||||
assert.deepEqual((payload.system as Array<Record<string, unknown>>)[0].cache_control, {
|
||||
type: "ephemeral",
|
||||
});
|
||||
});
|
||||
|
||||
test("a marker relocated onto a tool_result survives its collapse into text", () => {
|
||||
const payload: Record<string, unknown> = {
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }] },
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "note", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "turn 2" }] },
|
||||
],
|
||||
};
|
||||
|
||||
// Without preserveToolResultBlocks the tool_result is rewritten into a plain text block.
|
||||
normalizeClaudeUpstreamMessages(payload);
|
||||
|
||||
const messages = payload.messages as Array<{ content: Array<Record<string, unknown>> }>;
|
||||
assert.equal(messages[0].content[0].type, "text");
|
||||
assert.match(messages[0].content[0].text as string, /^\[Tool Result: toolu_1\]/);
|
||||
assert.deepEqual(messages[0].content[0].cache_control, { type: "ephemeral" });
|
||||
assert.deepEqual(layout(payload), { sys: 0, msg: 1 });
|
||||
});
|
||||
|
||||
test("a marker relocated onto an inlined document survives its conversion to text", () => {
|
||||
const payload: Record<string, unknown> = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "document", name: "notes.txt", document: {}, content: "document body" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "note", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
normalizeClaudeUpstreamMessages(payload);
|
||||
|
||||
const messages = payload.messages as Array<{ content: Array<Record<string, unknown>> }>;
|
||||
assert.equal(messages[0].content[0].type, "text");
|
||||
assert.match(messages[0].content[0].text as string, /^\[notes\.txt\]\ndocument body$/);
|
||||
assert.deepEqual(messages[0].content[0].cache_control, { type: "ephemeral" });
|
||||
assert.deepEqual(layout(payload), { sys: 0, msg: 1 });
|
||||
});
|
||||
|
||||
test("#9436 holds on the native Claude path through normalizeClaudeUpstreamMessages", () => {
|
||||
// Path proof: the hoist is not confined to the semantic-passthrough branch — the function the
|
||||
// native Claude passthrough calls routes through extractSystemRoleMessages.
|
||||
const payload = claudeCodeTurn("systemNote");
|
||||
normalizeClaudeUpstreamMessages(payload, { preserveToolResultBlocks: true });
|
||||
|
||||
const messages = payload.messages as Array<{ role: string }>;
|
||||
assert.ok(!messages.some((m) => m.role === "system"));
|
||||
assert.deepEqual(layout(payload), { sys: 1, msg: 1 });
|
||||
});
|
||||
Reference in New Issue
Block a user