Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
115 changes: 115 additions & 0 deletions artifacts/issue-3900-live-cpa-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Live probe for issue #3900, via the CPA proxy configured in
// ~/.gjc/agent/models.yml (fallback credentials — no direct Anthropic key on
// this machine).
//
// Step 1: run a real tool-use turn with thinking enabled and capture the
// genuinely signed thinking block.
// Step 2: tamper the thinking text (signature now mismatches), append the
// tool_result, and continue the turn. Anthropic rejects exactly this shape
// with the "thinking ... cannot be modified" 400; behind CPA it can arrive
// as a statusless SSE error event. Expected: the provider classifies the
// rejection, runs the thinking-replay repair, and the turn recovers.
import * as os from "node:os";
import * as path from "node:path";
import { Effort } from "../packages/ai/src/model-thinking";
import { streamAnthropic } from "../packages/ai/src/providers/anthropic";
import type { Context, Model, ToolResultMessage, UserMessage } from "../packages/ai/src/types";

const modelsYml = await Bun.file(path.join(os.homedir(), ".gjc", "agent", "models.yml")).text();
const anthropicBlock = /anthropic:\n(?:\s+.+\n?)+?(?=\n\S|$)/.exec(modelsYml)?.[0] ?? "";
const baseUrl = /baseUrl:\s*(\S+)/.exec(anthropicBlock)?.[1];
const apiKey = /apiKey:\s*"?([^"\n]+)"?/.exec(anthropicBlock)?.[1];
if (!baseUrl || !apiKey) throw new Error("models.yml fallback credentials not found");

const modelId = process.argv[2] ?? "claude-opus-5";
const model: Model<"anthropic-messages"> = {
api: "anthropic-messages",
provider: "anthropic",
id: modelId,
name: modelId,
baseUrl,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
maxTokens: 32_000,
contextWindow: 200_000,
reasoning: true,
thinking: { mode: "anthropic-adaptive", minLevel: Effort.Minimal, maxLevel: Effort.XHigh },
};

const tools: Context["tools"] = [
{
name: "ping",
description: "returns pong",
parameters: { type: "object", properties: {}, required: [] } as never,
},
];
const user: UserMessage = {
role: "user",
content: "Think briefly about why you must call the ping tool, then call it exactly once.",
timestamp: Date.now(),
};

// Step 1: obtain a genuinely signed thinking + tool_use turn.
const firstTurn = await streamAnthropic(
model,
{ systemPrompt: ["Use the ping tool when asked."], tools, messages: [user] },
{ apiKey, isOAuth: false, thinkingEnabled: true, effort: "xhigh", maxTokens: 4_096 },
).result();
const thinkingBlock = firstTurn.content.find(b => b.type === "thinking");
const toolCall = firstTurn.content.find(b => b.type === "toolCall");
if (firstTurn.stopReason !== "toolUse" || !toolCall) {
console.log(JSON.stringify({ step: 1, stopReason: firstTurn.stopReason, error: firstTurn.errorMessage }));
throw new Error("step 1 did not produce a tool_use turn");
}
const signature = thinkingBlock?.type === "thinking" ? thinkingBlock.thinkingSignature : undefined;
console.log(
JSON.stringify({
step: 1,
stopReason: firstTurn.stopReason,
hasSignedThinking: !!signature,
signaturePrefix: signature?.slice(0, 12),
}),
);

// Step 2: tamper the signed thinking text and continue with the tool result.
if (thinkingBlock?.type === "thinking") {
thinkingBlock.thinking = `${thinkingBlock.thinking} [TAMPERED issue #3900]`;
}
const toolResult: ToolResultMessage = {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [{ type: "text", text: "pong" }],
isError: false,
timestamp: Date.now() + 1,
};
const payloads: string[] = [];
const secondTurn = await streamAnthropic(
model,
{ systemPrompt: ["Use the ping tool when asked."], tools, messages: [user, firstTurn, toolResult] },
{
apiKey,
isOAuth: false,
thinkingEnabled: true,
effort: "xhigh",
maxTokens: 4_096,
onPayload: payload => {
payloads.push(JSON.stringify(payload));
return undefined;
},
},
).result();

const report = {
step: 2,
baseUrl,
model: modelId,
requests: payloads.length,
firstRequestHadTamperedThinking: payloads[0]?.includes("TAMPERED issue #3900") ?? false,
lastRequestHadTamperedThinking: payloads.at(-1)?.includes("TAMPERED issue #3900") ?? false,
stopReason: secondTurn.stopReason,
errorMessage: secondTurn.errorMessage,
text: secondTurn.content.filter(b => b.type === "text").map(b => (b as { text: string }).text),
};
console.log(JSON.stringify(report, null, 2));
if (secondTurn.stopReason !== "stop") process.exit(1);
98 changes: 98 additions & 0 deletions artifacts/issue-3900-sse-proxy-sim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Issue #3900 wire-level simulation: a local proxy that behaves like
// CLIProxyAPI — it answers HTTP 200 and delivers Anthropic's 400 body as an
// in-stream SSE `error` event (the exact captured rejection). The second
// request succeeds. Runs the real streamAnthropic + Anthropic SDK transport,
// so it exercises iterateAnthropicEvents' statusless error throw and the
// thinking-replay repair end-to-end without any credentials.
import { streamAnthropic } from "../packages/ai/src/providers/anthropic";
import type { AssistantMessage, Context, Model, UserMessage } from "../packages/ai/src/types";

// `masked` reproduces the live 2026-08-06 CPA capture: the proxy replaces the
// upstream body entirely, so the client only sees a generic `api_error`.
const capturedError =
process.argv[2] === "masked"
? '{"type":"error","error":{"type":"api_error","message":"An error occurred while processing the request."}}'
: '{"type":"error","error":{"type":"invalid_request_error","message":"messages.5.content.1: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response."}}';

const successFrames = [
['message_start', '{"type":"message_start","message":{"id":"msg_sim","usage":{"input_tokens":1,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}'],
['content_block_start', '{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}'],
['content_block_delta', '{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"recovered"}}'],
['content_block_stop', '{"type":"content_block_stop","index":0}'],
['message_delta', '{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}'],
['message_stop', '{"type":"message_stop"}'],
] as const;

const requestBodies: string[] = [];
const server = Bun.serve({
port: 0,
async fetch(req) {
if (!new URL(req.url).pathname.endsWith("/v1/messages")) return new Response("not found", { status: 404 });
requestBodies.push(await req.text());
const frames =
requestBodies.length === 1
? [`event: error\ndata: ${capturedError}\n\n`]
: successFrames.map(([event, data]) => `event: ${event}\ndata: ${data}\n\n`);
return new Response(frames.join(""), {
status: 200,
headers: { "content-type": "text/event-stream", "request-id": `req_sim_${requestBodies.length}` },
});
},
});

const model: Model<"anthropic-messages"> = {
api: "anthropic-messages",
provider: "anthropic",
id: "claude-opus-5",
name: "claude-opus-5",
baseUrl: `http://127.0.0.1:${server.port}`,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
maxTokens: 8_192,
contextWindow: 200_000,
reasoning: true,
};
const user: UserMessage = { role: "user", content: "first", timestamp: Date.now() };
const assistant: AssistantMessage = {
role: "assistant",
content: [
{ type: "thinking", thinking: "signed replay thinking", thinkingSignature: "sig_issue_3900" },
{ type: "text", text: "history answer" },
],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-opus-5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
const context: Context = {
messages: [user, assistant, { ...user, content: "next prompt", timestamp: Date.now() + 1 }],
};

const result = await streamAnthropic(model, context, {
apiKey: "sk-ant-api-sim",
isOAuth: false,
thinkingEnabled: true,
}).result();
server.stop(true);

const report = {
requests: requestBodies.length,
firstRequestHadSignedThinking: requestBodies[0]?.includes("sig_issue_3900") ?? false,
repairedRequestDroppedThinking: requestBodies[1] !== undefined && !requestBodies[1].includes("sig_issue_3900"),
stopReason: result.stopReason,
errorMessage: result.errorMessage,
text: result.content.filter(b => b.type === "text").map(b => (b as { text: string }).text),
};
console.log(JSON.stringify(report, null, 2));
if (result.stopReason !== "stop" || requestBodies.length !== 2 || !report.repairedRequestDroppedThinking) {
process.exit(1);
}
3 changes: 3 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
- Anthropic Sonnet 5 now exposes Anthropic's real `xhigh` and `max` thinking efforts on the Messages API (`minimal`/`low`/`medium`/`high`/`xhigh`/`max`), matching official support. The previous generic `kind === opus` gate excluded it from the full preset range; the capability predicate is now an explicit version-scoped list (Opus 4.7+, Sonnet 5+), so older Sonnet generations and Bedrock Converse routes stay fail-closed at their previously advertised levels (issue #3913).
- Alibaba Token Plan now exposes Qwen 3.8 Max under the provider-supported `qwen3.8-max` wire id instead of the rejected `qwen-3.8-max` spelling; catalog regeneration canonicalizes a legacy discovered alias rather than retaining a broken duplicate (#3909).
- Canonicalized first-class MiniMax M3 catalog ids (issue #3896). The bundled catalog previously shipped stale lowercase `minimax-m3` duplicates (512K) next to the canonical `MiniMax-M3` (1M) on all four first-class MiniMax providers, plus a non-official `minimax-v3` entry under `minimax-code`. The lowercase `minimax-m3` entries and `minimax-v3` are removed; `MiniMax-M3` is the single canonical first-class id (the regen-safe 1M pin in `applyGeneratedModelPolicy` now keys on `MiniMax-M3` / `MiniMax-M3[1m]` instead of the removed lowercase id), `DEFAULT_MODEL_PER_PROVIDER` points at `MiniMax-M3`, and the official Anthropic Token Plan id `MiniMax-M3[1m]` is first-class on the `minimax` / `minimax-cn` Anthropic routes with 1M context semantics. Unrelated catalog providers keep their own `minimax-m3` contracts.
- Anthropic thinking-replay repair now also triggers when the mutation/signature `invalid_request_error` arrives as a statusless in-stream SSE `error` event (issue #3900). Proxies such as CLIProxyAPI forward the upstream 400 body over an HTTP 200 SSE stream, so the thrown error carries no HTTP status; the classifiers previously required `status === 400` and let the session loop on an unrecoverable replay rejection. Statusless errors still require the full `invalid_request_error` thinking wording, so unrelated transport failures never claim the one-shot repair.
- Anthropic thinking-replay repair now also recovers when a proxy masks the rejection entirely (issue #3900). Live CLIProxyAPI captures replace the upstream 400 body with a generic `{"type":"api_error","message":"An error occurred while processing the request."}` SSE event on an HTTP 200 response, which names no cause and matches no transient phrase, so the turn died on the first attempt. Such a masked rejection now takes the same one-shot latest-then-full-history repair, but only before the first token and only while the request actually replays signed `thinking`/`redacted_thinking` blocks; masked failures on requests without replayed thinking still surface immediately.

- Anthropic cache-control resolution now falls back to `model.cacheRetention` at the provider boundary, preserving configured retention and request-over-model precedence through special dispatch wrappers such as GitLab Duo. A configured `cacheRetention: "none"` can no longer be dropped and replaced by the new automatic Claude-family cache marker.
## [0.12.12] - 2026-08-05

Expand Down
44 changes: 41 additions & 3 deletions packages/ai/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,8 +395,20 @@ export function isAnthropicFastModeUnsupportedError(error: unknown): boolean {
return false;
}

/**
* Proxies (e.g. CLIProxyAPI) can deliver Anthropic's 400 body as an in-stream
* SSE `error` event on an HTTP 200 response; the thrown error then carries no
* HTTP status at all (issue #3900). Accept both the direct 400 and the
* statusless SSE shape — the strict `invalid_request_error` message checks in
* each matcher keep the statusless branch from claiming unrelated failures.
*/
function isAnthropicInvalidRequestStatus(error: unknown): boolean {
const status = extractHttpStatusFromError(error);
return status === 400 || status === undefined;
}

export function isAnthropicThinkingBlockMutationError(error: unknown): boolean {
if (extractHttpStatusFromError(error) !== 400) return false;
if (!isAnthropicInvalidRequestStatus(error)) return false;
const message = error instanceof Error ? error.message : String(error);
return (
/invalid_request_error/i.test(message) &&
Expand All @@ -414,7 +426,7 @@ export function isAnthropicThinkingBlockMutationError(error: unknown): boolean {
* than only the latest one.
*/
export function isAnthropicThinkingSignatureInvalidError(error: unknown): boolean {
if (extractHttpStatusFromError(error) !== 400) return false;
if (!isAnthropicInvalidRequestStatus(error)) return false;
const message = error instanceof Error ? error.message : String(error);
return (
/invalid_request_error/i.test(message) &&
Expand All @@ -423,6 +435,27 @@ export function isAnthropicThinkingSignatureInvalidError(error: unknown): boolea
);
}

/**
* CLIProxyAPI replaces Anthropic's rejection body wholesale instead of forwarding
* it: the client only ever sees
* `{"type":"error","error":{"type":"api_error","message":"An error occurred while
* processing the request."}}`, delivered as an in-stream SSE `error` event on an
* HTTP 200 response, so neither the status nor the message survives. Captured CPA
* traces for that masked shape carry the thinking-integrity 400 upstream (issue
* #3900), and the generic body matches no transient phrase either, so the turn
* dies unrecoverably. Nothing in the payload names the cause; callers must pair
* this with a request that actually replays signed thinking blocks before
* treating it as a thinking-replay rejection.
*/
function isAnthropicMaskedProxyRejection(error: unknown): boolean {
const status = extractHttpStatusFromError(error);
if (status !== undefined && status !== 400) return false;
const message = error instanceof Error ? error.message : String(error);
// A body that still names its error type is classified by the strict matchers.
if (/invalid_request_error/i.test(message)) return false;
return /"type"\s*:\s*"api_error"/.test(message) && /an error occurred while processing/i.test(message);
}

function hasStrictAnthropicTools(params: MessageCreateParamsStreaming): boolean {
const tools = params.tools as Array<{ strict?: unknown }> | undefined;
return tools?.some(tool => tool.strict === true) ?? false;
Expand Down Expand Up @@ -1856,7 +1889,12 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
!options?.fallbackManaged &&
!repairAllAssistantThinking &&
firstTokenTime === undefined &&
(thinkingSignatureInvalid || isAnthropicThinkingBlockMutationError(streamFailure))
(thinkingSignatureInvalid ||
isAnthropicThinkingBlockMutationError(streamFailure) ||
// Masked proxy rejection: unclassifiable on its own, so the replayed
// request shape is the evidence. Without signed thinking blocks in
// flight there is nothing to repair and the error must surface.
(isAnthropicMaskedProxyRejection(streamFailure) && hasNativeThinkingBlocks(params.messages)))
) {
// The mutation 400 blames the "latest assistant message", but its cited
// `messages.N.content.M` path can point at an EARLIER replayed turn, so the
Expand Down
27 changes: 27 additions & 0 deletions packages/ai/test/anthropic-thinking-immutability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,33 @@ describe("Anthropic thinking replay 400 classification", () => {
expect(isAnthropicThinkingSignatureInvalidError(error)).toBe(false);
});

// Issue #3900: CLIProxyAPI delivers the upstream 400 body as an in-stream SSE
// `error` event on an HTTP 200 response, so the thrown error carries no HTTP
// status. Both matchers must still classify the invalid_request_error payload.
it("classifies the statusless SSE error-event mutation variant", () => {
const sseError = new Error(
'{"type":"error","error":{"type":"invalid_request_error","message":"messages.5.content.1: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response."}}',
);
expect(isAnthropicThinkingBlockMutationError(sseError)).toBe(true);
expect(isAnthropicThinkingSignatureInvalidError(sseError)).toBe(false);
});

it("classifies the statusless SSE error-event signature variant", () => {
const sseError = new Error(
'{"type":"error","error":{"type":"invalid_request_error","message":"messages.5.content.24: Invalid `signature` in `thinking` block"}}',
);
expect(isAnthropicThinkingSignatureInvalidError(sseError)).toBe(true);
expect(isAnthropicThinkingBlockMutationError(sseError)).toBe(false);
});

it("rejects statusless masked proxy errors without thinking attribution", () => {
const masked = new Error(
'{"type":"error","error":{"type":"api_error","message":"An error occurred while processing the request."}}',
);
expect(isAnthropicThinkingBlockMutationError(masked)).toBe(false);
expect(isAnthropicThinkingSignatureInvalidError(masked)).toBe(false);
});

it("rejects non-Error inputs and unrelated thinking-config 400s", () => {
expect(isAnthropicThinkingSignatureInvalidError(undefined)).toBe(false);
expect(isAnthropicThinkingSignatureInvalidError("Invalid `signature` in `thinking` block")).toBe(false);
Expand Down
Loading
Loading