fix(ai): route Bedrock dotted Claude profiles to Anthropic dialect and separate flattened bare demoted-thinking blocks

Two defense-in-depth follow-ups to the Anthropic-dialect demotion fix flagged by the Codex reviewer on #4432:

1. Extend isClaudeModelId's regex from `(^|/)claude[-.]` to `(^|[/.])claude[-.]` so Bedrock cross-region inference profiles (us.anthropic.claude-…, eu.anthropic.claude-…, global.anthropic.claude-…, au.anthropic.claude-…) classify as Claude. parseAnthropicModel only enumerates opus/sonnet/fable/mythos, so a Haiku Bedrock profile whose kind isn't in the parser regex would otherwise slip through modelFamilyToken's fallback and fall through preferredDialect to XML, still emitting <thinking>…</thinking> on prior-turn demotion.

2. Join adjacent text blocks with \n (was "") when flattening assistant content in convertOpenAICompletionsMessages. Anthropic-dialect renderDemotedThinking returns bare prose with no self-terminator, so a demoted-reasoning text block followed by a visible-answer text block used to concatenate as "reasoningfinal answer". Streaming accumulates continuous prose into a single block, so multi-block content represents semantically distinct segments; the paragraph-break join is the right shape.

Test coverage: identity-family adds dotted-prefix cases for isClaudeModelId and modelFamilyToken; transform-messages-thinking-dialect extends the Claude-target sweep with Bedrock profile ids; issue-3434/3528 repro tests updated to expect the newline-separated flatten shape.

Refs #4430
This commit is contained in:
roboomp
2026-07-03 20:54:35 +00:00
parent 622958522f
commit 1913d354e1
8 changed files with 57 additions and 10 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
### Fixed
- Fixed prior-turn reasoning demotion for Anthropic Claude models by generalizing the Fable-only bare-prose branch to the whole Anthropic dialect, so cross-model replays to Opus, Sonnet, Haiku, and Mythos no longer emit `<thinking>` tags that Anthropic's reasoning_extraction classifier flags as visible reasoning leakage ([#4430](https://github.com/can1357/oh-my-pi/issues/4430)).
- Fixed prior-turn reasoning demotion for Anthropic Claude models by generalizing the Fable-only bare-prose branch to the whole Anthropic dialect, so cross-model replays to Opus, Sonnet, Haiku, and Mythos no longer emit `<thinking>` tags that Anthropic's reasoning_extraction classifier flags as visible reasoning leakage. Also extended the Claude-id fallback classifier to Bedrock cross-region inference profiles (`us.anthropic.claude-…`, `eu.anthropic.claude-…`, etc.) so Haiku dotted profiles route to the Anthropic dialect, and separated demoted-thinking from the following visible answer in the openai-completions flatten path so bare Anthropic-dialect reasoning no longer runs into the reply text ([#4430](https://github.com/can1357/oh-my-pi/issues/4430)).
## [16.3.4] - 2026-07-03
@@ -1778,7 +1778,11 @@ export function convertMessages(
// Always send assistant content as a plain string. Some OpenAI-compatible
// backends mirror array-of-text-block payloads back to the model literally,
// causing recursive nested content in subsequent turns.
assistantMsg.content = nonEmptyTextBlocks.map(b => b.text.toWellFormed()).join("");
// Separate distinct text blocks with a newline: for Anthropic-dialect
// targets `renderDemotedThinking` returns bare prose (no trailing
// delimiter), so a demoted-thinking block would otherwise run into the
// following visible-answer block ("reasoningfinal answer").
assistantMsg.content = nonEmptyTextBlocks.map(b => b.text.toWellFormed()).join("\n");
}
// Handle thinking blocks
+4 -4
View File
@@ -194,7 +194,7 @@ describe("cross-API thinking-block preservation (#3433/#3434)", () => {
if (!assistant) throw new Error("assistant message missing");
expect(assistant.reasoning_content).toBe("");
expect(assistant.content).toBe(`${renderDemotedThinking(target.id, "Read README and answer.")}Done.`);
expect(assistant.content).toBe(`${renderDemotedThinking(target.id, "Read README and answer.")}\nDone.`);
});
it("demotes thinking to canonical text when the target cannot replay it semantically", () => {
@@ -211,7 +211,7 @@ describe("cross-API thinking-block preservation (#3433/#3434)", () => {
if (!assistant) throw new Error("assistant message missing");
expect(assistant.reasoning_content).toBeUndefined();
expect(assistant.content).toBe(`${renderDemotedThinking(target.id, "Explore the repo, then patch it.")}Done.`);
expect(assistant.content).toBe(`${renderDemotedThinking(target.id, "Explore the repo, then patch it.")}\nDone.`);
});
it("demotes cross-API thinking for OpenCode reasoning targets with whenThinking schema", () => {
@@ -233,7 +233,7 @@ describe("cross-API thinking-block preservation (#3433/#3434)", () => {
if (!assistant) throw new Error("assistant message missing");
expect(assistant.reasoning_content).toBeUndefined();
expect(assistant.content).toBe(`${renderDemotedThinking(target.id, "Read README and answer.")}Done.`);
expect(assistant.content).toBe(`${renderDemotedThinking(target.id, "Read README and answer.")}\nDone.`);
});
it("demotes prior thinking to content when the OpenCode base compat runs with thinking off", () => {
@@ -253,7 +253,7 @@ describe("cross-API thinking-block preservation (#3433/#3434)", () => {
if (!assistant) throw new Error("assistant message missing");
expect(assistant.reasoning_content).toBeUndefined();
expect(assistant.content).toBe(`${renderDemotedThinking(target.id, "Read README and answer.")}Done.`);
expect(assistant.content).toBe(`${renderDemotedThinking(target.id, "Read README and answer.")}\nDone.`);
});
it("does not promote markup-healed same-model thinking into visible content", () => {
+1 -1
View File
@@ -284,7 +284,7 @@ describe("llama.cpp warm-prefix preservation (#3528)", () => {
const found = findAssistantMessage(wire) as Record<string, unknown> | undefined;
expect(found?.reasoning_content).toBeUndefined();
expect(found?.content).toBe(
`${renderDemotedThinking(target.id, "Cross-vendor reasoning chain that must survive the switch.")}Switched-in answer.`,
`${renderDemotedThinking(target.id, "Cross-vendor reasoning chain that must survive the switch.")}\nSwitched-in answer.`,
);
expect("EvAnthropicOpaqueContinuationBlob==" in (found ?? {})).toBe(false);
});
@@ -233,7 +233,14 @@ describe("openai-completions compatibility", () => {
throw new Error("assistant message missing");
}
expect(typeof assistant.content).toBe("string");
expect(assistant.content).toBe("hello world");
// Distinct text blocks are joined with `\n` — see the flatten path in
// openai-completions.ts. Streaming accumulates chunks into a single
// block, so multi-block content represents semantically separate
// segments (a demoted-thinking block followed by the visible answer,
// or a text block flanking a tool call). Bare Anthropic-dialect
// demoted reasoning has no self-terminator, so the join site MUST
// insert one to keep the two segments from running together.
expect(assistant.content).toBe("hello\n world");
});
it("prepends thinking text to string assistant content when requiresThinkingAsText is set", () => {
@@ -143,6 +143,12 @@ describe("transformMessages cross-provider thinking demotion → canonical diale
{ name: "Claude Haiku", id: "claude-haiku-4-5" },
{ name: "Claude Fable", id: "claude-fable-5" },
{ name: "Claude Mythos", id: "claude-mythos-5" },
// Bedrock cross-region inference profiles. `parseAnthropicModel`
// doesn't enumerate `haiku`, so this exercises the isClaudeModelId
// dotted-prefix fallback specifically.
{ name: "Claude Haiku (Bedrock US)", id: "us.anthropic.claude-haiku-4-5-20251001-v1:0" },
{ name: "Claude Haiku (Bedrock EU)", id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0" },
{ name: "Claude Opus (Bedrock Global)", id: "global.anthropic.claude-opus-4-8" },
] as const;
for (const target of targets) {
+9 -2
View File
@@ -41,9 +41,16 @@ export const isKimiK26ModelId = memo((modelId: string): boolean => {
return /(^|\/)kimi-k2(?:\.6|p6)(?:[-:]|$)/i.test(modelId);
});
/** Claude ids in any namespace form (`claude-*`, `vendor/claude.x`). */
/**
* Claude ids in any namespace form: bare (`claude-*`), path-namespaced
* (`anthropic/claude.x`), or dot-prefixed (`us.anthropic.claude-…`,
* `global.anthropic.claude-…`, `au.anthropic.claude-…` — Bedrock cross-region
* inference profiles). Necessary because {@link parseAnthropicModel} only
* classifies kinds enumerated in its regex, so any dotted profile whose kind
* (e.g. `haiku`) is not enumerated would otherwise slip past this fallback.
*/
export const isClaudeModelId = memo((modelId: string): boolean => {
return /(^|\/)claude[-.]/i.test(modelId);
return /(^|[/.])claude[-.]/i.test(modelId);
});
/** `anthropic/`-namespaced ids (aggregator catalogs like OpenRouter). */
@@ -43,6 +43,21 @@ describe("isClaudeModelId", () => {
expect(isClaudeModelId("anthropic/claude.3")).toBe(true);
expect(isClaudeModelId("my-claudius")).toBe(false);
});
test("matches dotted Bedrock cross-region inference profile ids for Claude kinds not enumerated in parseAnthropicModel", () => {
// `parseAnthropicModel` only classifies opus/sonnet/fable/mythos, so a
// Haiku Bedrock profile (`us.anthropic.claude-haiku-…`) slips past its
// regex and MUST still classify as Claude via this fallback so
// `modelFamilyToken`/`preferredDialect` route it to the Anthropic
// dialect instead of falling through to XML.
expect(isClaudeModelId("us.anthropic.claude-haiku-4-5-20251001-v1:0")).toBe(true);
expect(isClaudeModelId("eu.anthropic.claude-haiku-4-5-20251001-v1:0")).toBe(true);
expect(isClaudeModelId("global.anthropic.claude-haiku-4-5-20251001-v1:0")).toBe(true);
expect(isClaudeModelId("au.anthropic.claude-haiku-4-5-20251001-v1:0")).toBe(true);
// Non-Claude names that happen to contain "claude" as a substring
// stay unmatched — no `.` / `/` / start delimiter before `claude-`.
expect(isClaudeModelId("subclaudian")).toBe(false);
expect(isClaudeModelId("claudius-5")).toBe(false);
});
});
describe("supportsAdaptiveThinkingDisplay", () => {
@@ -211,6 +226,14 @@ describe("modelFamilyToken", () => {
expect(modelFamilyToken("openrouter/anthropic/claude-opus-4-8")).toBe("anthropic");
});
test("classifies Bedrock cross-region profile ids for Claude kinds not enumerated in parseAnthropicModel", () => {
// `parseAnthropicModel` doesn't know `haiku`, so this exercises the
// isClaudeModelId fallback specifically for dotted Bedrock profiles.
expect(modelFamilyToken("us.anthropic.claude-haiku-4-5-20251001-v1:0")).toBe("anthropic");
expect(modelFamilyToken("eu.anthropic.claude-haiku-4-5-20251001-v1:0")).toBe("anthropic");
expect(modelFamilyToken("global.anthropic.claude-haiku-4-5-20251001-v1:0")).toBe("anthropic");
});
test("classifies non-first-party families", () => {
expect(modelFamilyToken("moonshotai/kimi-k2")).toBe("kimi");
expect(modelFamilyToken("qwen/qwen3-coder")).toBe("qwen");