mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +03:00
The upstream Messages API rejects directive-style messages (empty content array with a message-level output_config) when they sit at messages[0] — the initial system prompt position — while accepting the form at any other position. Measured in production: 122x 400 on the offical-claude combo in one hour. The mid-conversation-system passthrough (official provider + 1M-context beta models) keeps system-role messages inside messages[], so a directive that arrived first went upstream unchanged. relocateDirectiveOnlyMessages() moves the whole leading run of empty system messages: directive-only ones past the first real turn, plain empties dropped. extractSystemRoleMessages() now folds a directive's output_config into the top-level parameter instead of silently discarding it. Signed-off-by: Minxi Hou <houminxi@gmail.com>
248 lines
10 KiB
TypeScript
248 lines
10 KiB
TypeScript
/**
|
|
* chatCore Claude system-role lifter (Quality Gate v2 / Fase 9 — chatCore god-file
|
|
* decomposition, #3501).
|
|
*
|
|
* Pure helper extracted from chatCore.ts: lifts any `system`/`developer` role messages out of the
|
|
* messages[] array into the top-level `system` field. Anthropic's Messages API rejects either as a
|
|
* 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 }>;
|
|
// Treat both `system` and `developer` as system-equivalent (OpenAI's Responses
|
|
// API renamed system → developer). Anthropic rejects either as a chat role, so
|
|
// both must be lifted into the top-level `system` field — parity with the
|
|
// normal-path extractSystemMessagesToBody closure.
|
|
const isSystemRole = (role: unknown): boolean =>
|
|
typeof role === "string" &&
|
|
(role.toLowerCase() === "system" || role.toLowerCase() === "developer");
|
|
const systemMessages = messages.filter((m) => isSystemRole(m.role));
|
|
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;
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
// Directive payload (message-level output_config, as emitted by Claude
|
|
// Code clients): the message itself is lifted away, so fold its output
|
|
// configuration into the top-level parameter instead of silently dropping
|
|
// it — whatever shape the content had. An explicit top-level output_config
|
|
// wins, and among several directive messages the first one wins.
|
|
if (payload.output_config == null) {
|
|
const directive = sm as Record<string, unknown>;
|
|
if (
|
|
directive.output_config != null &&
|
|
typeof directive.output_config === "object" &&
|
|
!Array.isArray(directive.output_config)
|
|
) {
|
|
payload.output_config = directive.output_config;
|
|
}
|
|
}
|
|
}
|
|
if (extraBlocks.length > 0) {
|
|
const existingSystem = payload.system;
|
|
if (typeof existingSystem === "string" && existingSystem.length > 0) {
|
|
payload.system = [{ type: "text", text: existingSystem }, ...extraBlocks];
|
|
} else if (Array.isArray(existingSystem)) {
|
|
payload.system = [...(existingSystem as Array<Record<string, unknown>>), ...extraBlocks];
|
|
} else {
|
|
payload.system = extraBlocks;
|
|
}
|
|
}
|
|
payload.messages = messages.filter((m) => !isSystemRole(m.role));
|
|
}
|
|
|
|
/**
|
|
* Moves a directive-only system message (empty content array + message-level
|
|
* `output_config`, the shape Claude Code clients emit) off `messages[0]`.
|
|
*
|
|
* Anthropic treats `messages[0]` as the initial system prompt position and
|
|
* rejects the directive-only form there ("use the top-level 'system' parameter
|
|
* for the initial system prompt"), while accepting it at any other position.
|
|
* The mid-conversation-system passthrough (provider `claude` + 1M-context beta
|
|
* models) deliberately keeps system-role messages inside `messages[]`, so a
|
|
* directive that arrived first would go upstream unchanged and 400. Relocate it
|
|
* past the first real turn instead; when the conversation has no real turn at
|
|
* all, fold the `output_config` into the top-level parameter (which wins when
|
|
* already present) and drop the now-empty message.
|
|
*/
|
|
export function relocateDirectiveOnlyMessages(payload: Record<string, unknown>): void {
|
|
if (!Array.isArray(payload.messages) || payload.messages.length === 0) return;
|
|
const messages = payload.messages as Array<Record<string, unknown>>;
|
|
const isSystemRole = (role: unknown): boolean =>
|
|
typeof role === "string" &&
|
|
(role.toLowerCase() === "system" || role.toLowerCase() === "developer");
|
|
const isEmptySystem = (m: Record<string, unknown>): boolean =>
|
|
m != null &&
|
|
typeof m === "object" &&
|
|
isSystemRole(m.role) &&
|
|
Array.isArray(m.content) &&
|
|
m.content.length === 0;
|
|
const isDirectiveOnly = (m: Record<string, unknown>): boolean =>
|
|
isEmptySystem(m) &&
|
|
m.output_config != null &&
|
|
typeof m.output_config === "object" &&
|
|
!Array.isArray(m.output_config);
|
|
|
|
if (!isEmptySystem(messages[0])) {
|
|
return;
|
|
}
|
|
|
|
// Collect the whole leading run of empty system messages so consecutive
|
|
// directives are all relocated in one pass (handling only messages[0] would
|
|
// leave the second directive at the rejected position).
|
|
let runEnd = 0;
|
|
while (runEnd < messages.length && isEmptySystem(messages[runEnd])) {
|
|
runEnd++;
|
|
}
|
|
const lead = messages.slice(0, runEnd);
|
|
const directives = lead.filter(isDirectiveOnly);
|
|
|
|
// First real (user/assistant) turn after the run. System messages with text
|
|
// content are not safe insertion anchors — keep walking past them, and past
|
|
// any non-object entries a malformed body may carry.
|
|
let insertAfter = -1;
|
|
for (let i = runEnd; i < messages.length; i++) {
|
|
const candidate = messages[i];
|
|
if (
|
|
candidate != null &&
|
|
typeof candidate === "object" &&
|
|
!isSystemRole(candidate.role)
|
|
) {
|
|
insertAfter = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (insertAfter === -1) {
|
|
// No real turn to relocate after: fold the first directive's
|
|
// output_config into the top-level parameter (an explicit top-level value
|
|
// wins) and drop the whole run.
|
|
if (payload.output_config == null && directives.length > 0) {
|
|
payload.output_config = directives[0].output_config;
|
|
}
|
|
payload.messages = messages.slice(runEnd);
|
|
return;
|
|
}
|
|
|
|
// Move the directives (in order) past the first real turn; plain empty
|
|
// system messages carry nothing and are dropped.
|
|
payload.messages = [
|
|
...messages.slice(runEnd, insertAfter + 1),
|
|
...directives,
|
|
...messages.slice(insertAfter + 1),
|
|
];
|
|
}
|