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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(sse):** Claude-native context handoffs now land in Anthropic's top-level `system` parameter instead of a leading `role: "system"` message, and the final Claude executor dispatch hoists any remaining leading prompt system/developer messages and relocates directive-only `output_config` envelopes away from `messages[0]`, preventing the `messages.0: use the top-level 'system' parameter` HTTP 400 on model switches ([#12668](https://github.com/diegosouzapw/OmniRoute/pull/12668)).
4 changes: 2 additions & 2 deletions open-sse/executors/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import {
appendAnthropicBetaHeader,
CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA,
CONTEXT_1M_BETA_HEADER,
enforceThinkingTemperature,
finalizeClaudeBodyConstraints,
modelHasNativeContext1m,
modelSupportsContext1mBeta,
} from "../services/claudeCodeCompatible.ts";
Expand Down Expand Up @@ -1368,7 +1368,7 @@ export class BaseExecutor {
// routing mode (grouped/raw/combo) and the native passthrough share,
// before fingerprinting and CCH signing serialize the body.
if (this.provider === "claude" || usesClaudeCodeProtocol) {
enforceThinkingTemperature(transformedBody as Record<string, unknown>);
finalizeClaudeBodyConstraints(transformedBody as Record<string, unknown>);
}

// Delegated Context Editing (opt-in): attach the clear_tool_uses strategy so
Expand Down
86 changes: 4 additions & 82 deletions open-sse/handlers/chatCore/claudeSystemRole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@

export type HoistedCacheBoundary = "moved" | "kept" | "dropped";

// Re-exported from its canonical home in claudeCodeConstraints.ts so existing
// importers of this module keep working.
export { relocateDirectiveOnlyMessages } from "../../services/claudeCodeConstraints.ts";

/** 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;
Expand Down Expand Up @@ -163,85 +167,3 @@ export function extractSystemRoleMessages(payload: Record<string, unknown>): voi
}
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),
];
}
1 change: 1 addition & 0 deletions open-sse/services/claudeCodeCompatible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ export { computeFingerprint } from "./claudeCodeFingerprint.ts";
export { obfuscateSensitiveWords, setSensitiveWords } from "./claudeCodeObfuscation.ts";
export {
enforceThinkingTemperature,
finalizeClaudeBodyConstraints,
disableThinkingIfToolChoiceForced,
enforceCacheControlLimit,
} from "./claudeCodeConstraints.ts";
Expand Down
142 changes: 142 additions & 0 deletions open-sse/services/claudeCodeConstraints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,148 @@ export function enforceThinkingTemperature(body: Record<string, unknown>): void
}
}

/** Applies the final Anthropic wire-body invariants before serialization. */
export function finalizeClaudeBodyConstraints(body: Record<string, unknown>): void {
hoistLeadingSystemMessages(body);
relocateDirectiveOnlyMessages(body);
enforceThinkingTemperature(body);
}

function isSystemRole(role: unknown): boolean {
return (
typeof role === "string" &&
(role.toLowerCase() === "system" || role.toLowerCase() === "developer")
);
}

function hasOutputConfig(message: Record<string, unknown>): boolean {
return (
message.output_config != null &&
typeof message.output_config === "object" &&
!Array.isArray(message.output_config)
);
}

function isEmptySystemMessage(message: unknown): message is Record<string, unknown> {
if (message == null || typeof message !== "object") return false;
const candidate = message as Record<string, unknown>;
return (
isSystemRole(candidate.role) &&
Array.isArray(candidate.content) &&
candidate.content.length === 0
);
}

function isDirectiveOnlyMessage(message: unknown): boolean {
return isEmptySystemMessage(message) && hasOutputConfig(message);
}

/**
* Moves a directive-only system message (empty content array + message-level
* `output_config`) off `messages[0]`, which Anthropic reserves for the initial
* system-prompt position. Legitimate directives already later in the conversation
* stay untouched.
*/
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>>;
if (!isEmptySystemMessage(messages[0])) return;

let runEnd = 0;
while (runEnd < messages.length && isEmptySystemMessage(messages[runEnd])) runEnd++;
const directives = messages.slice(0, runEnd).filter(isDirectiveOnlyMessage);

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) {
if (payload.output_config == null && directives.length > 0) {
payload.output_config = directives[0].output_config;
}
payload.messages = messages.slice(runEnd);
return;
}

payload.messages = [
...messages.slice(runEnd, insertAfter + 1),
...directives,
...messages.slice(insertAfter + 1),
];
}

/** Extracts non-empty text blocks from string or array message content. */
function textBlocksFromContent(content: unknown): Array<Record<string, unknown>> {
if (typeof content === "string" && content.length > 0) {
return [{ type: "text", text: content }];
}
if (!Array.isArray(content)) return [];
const blocks: Array<Record<string, unknown>> = [];
for (const block of content) {
if (block == null || typeof block !== "object") continue;
const contentBlock = block as Record<string, unknown>;
if (
contentBlock.type === "text" &&
typeof contentBlock.text === "string" &&
contentBlock.text.length > 0
) {
blocks.push({ ...contentBlock });
}
}
return blocks;
}

/** Merges hoisted blocks into the existing top-level system value. */
function mergeSystemBlocks(
existing: unknown,
extra: Array<Record<string, unknown>>
): Array<Record<string, unknown>> {
if (typeof existing === "string" && existing.length > 0) {
return [{ type: "text", text: existing }, ...extra];
}
if (Array.isArray(existing)) {
return [...(existing as Array<Record<string, unknown>>), ...extra];
}
return extra;
}

/**
* Hoists only the initial system/developer run into Anthropic's top-level `system` field.
* Directive-only entries remain in `messages` for the positional relocation pass, and
* mid-conversation system entries remain untouched for the context-1m beta path.
*/
export function hoistLeadingSystemMessages(payload: Record<string, unknown>): void {
if (!Array.isArray(payload.messages) || payload.messages.length === 0) return;
const messages = payload.messages as Array<Record<string, unknown>>;

let runEnd = 0;
while (runEnd < messages.length && isSystemRole(messages[runEnd]?.role)) runEnd++;
if (runEnd === 0) return;

const extraBlocks: Array<Record<string, unknown>> = [];
const directives: Array<Record<string, unknown>> = [];
for (const message of messages.slice(0, runEnd)) {
if (isDirectiveOnlyMessage(message)) {
directives.push(message);
continue;
}
extraBlocks.push(...textBlocksFromContent(message.content));
if (payload.output_config == null && hasOutputConfig(message)) {
payload.output_config = message.output_config;
}
}

if (extraBlocks.length > 0) {
payload.system = mergeSystemBlocks(payload.system, extraBlocks);
}
payload.messages = [...directives, ...messages.slice(runEnd)];
}

export function disableThinkingIfToolChoiceForced(body: Record<string, unknown>): void {
const toolChoice = body.tool_choice as Record<string, unknown> | string | undefined;
if (!toolChoice) return;
Expand Down
1 change: 1 addition & 0 deletions open-sse/services/combo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,7 @@ async function handleComboChatInner({
stickyWeightedLimit,
getWeightedStepKeyForTarget,
universalHandoffConfig,
sourceFormat,
relayOptions,
relayConfig,
};
Expand Down
1 change: 1 addition & 0 deletions open-sse/services/combo/attemptLoopTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export type AttemptLoopDeps = {
stickyWeightedLimit?: number;
getWeightedStepKeyForTarget?: (target: ResolvedComboTarget) => string | null;
universalHandoffConfig?: UniversalHandoffConfig;
sourceFormat?: string | null;
relayOptions?: { sessionId?: string | null } | null;
relayConfig?: ContextRelayConfig | null;
};
Expand Down
18 changes: 9 additions & 9 deletions open-sse/services/combo/executeTargetAttempt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,14 +297,13 @@ export async function executeTargetAttempt(opts: {
}
}

// Universal handoff: inject existing handoff if model changed. i === 0
// only: a fallback target (i > 0) serves the SAME client request the
// failed primary target would have served, with the original messages
// already intact -- there's nothing to hand off, since the client never
// saw the earlier target fail. Injecting a handoff note there replaces
// real context with a context-free note, which weaker fallback models
// have been observed treating as license to fabricate content instead
// of just answering the actual request (#12227 follow-up).
// Universal handoff: inject on model change only when i === 0. A fallback
// target (i > 0) serves the SAME client request the failed primary target
// would have served, with the original messages already intact -- there is
// nothing to hand off, since the client never saw the earlier target fail.
// Injecting a handoff note there replaces real context with a context-free
// note, which weaker fallback models have been observed treating as license
// to fabricate content instead of answering the request (#12227 follow-up).
if (
i === 0 &&
universalHandoffConfig.enabled &&
Expand All @@ -320,7 +319,8 @@ export async function executeTargetAttempt(opts: {
modelStr,
`Model routing: ${lastModel} → ${modelStr}`,
existingHandoff,
universalHandoffConfig.relayMode
universalHandoffConfig.relayMode,
deps.sourceFormat
);
}
}
Expand Down
Loading
Loading