Merge remote-tracking branch 'origin/farm/4763e132/anthropic-context-management'
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Anthropic-compatible thinking requests sending replayed thinking blocks without `context_management.keep: "all"`, preserving multi-turn reasoning context for API-key providers. API-key requests now also advertise the required `context-management-2025-06-27` beta header so the field is honored instead of rejected. Injected SDK clients, GitHub Copilot's Anthropic proxy, and Vertex rawPredict are excluded because this code path cannot add the beta to caller-owned clients, Copilot strips Anthropic betas and demotes thinking blocks to text upstream, and Vertex expects betas in the JSON body rather than the Anthropic HTTP beta header. ([#3288](https://github.com/can1357/oh-my-pi/issues/3288))
|
||||
|
||||
## [16.1.15] - 2026-06-22
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -120,10 +120,11 @@ export function buildBetaHeader(baseBetas: readonly string[], extraBetas: readon
|
||||
}
|
||||
|
||||
const midConversationSystemBeta = "mid-conversation-system-2026-04-07";
|
||||
const contextManagementBeta = "context-management-2025-06-27";
|
||||
const claudeCodeUtilityBetaDefaults = [
|
||||
"oauth-2025-04-20",
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"context-management-2025-06-27",
|
||||
contextManagementBeta,
|
||||
"prompt-caching-scope-2026-01-05",
|
||||
"structured-outputs-2025-12-15",
|
||||
] as const;
|
||||
@@ -131,7 +132,7 @@ const claudeCodeAgentBetaDefaults = [
|
||||
"claude-code-20250219",
|
||||
"oauth-2025-04-20",
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"context-management-2025-06-27",
|
||||
contextManagementBeta,
|
||||
"prompt-caching-scope-2026-01-05",
|
||||
midConversationSystemBeta,
|
||||
"advanced-tool-use-2025-11-20",
|
||||
@@ -1680,6 +1681,22 @@ const streamAnthropicOnce = (
|
||||
// carry it in the Claude Code list).
|
||||
extraBetas.push(midConversationSystemBeta);
|
||||
}
|
||||
// `context_management.clear_thinking_20251015` requires this beta. OAuth
|
||||
// requests carry it in `claudeCodeAgentBetaDefaults`; API-key requests
|
||||
// need it added explicitly so the field is honored instead of rejected
|
||||
// (#3288). Skip transports where this package cannot deliver the beta
|
||||
// in the form their adapter accepts: Copilot strips Anthropic betas,
|
||||
// and Vertex rawPredict needs betas in the body (`anthropic_beta`),
|
||||
// not as an `anthropic-beta` HTTP header.
|
||||
if (
|
||||
model.reasoning &&
|
||||
options?.thinkingEnabled &&
|
||||
model.provider !== "github-copilot" &&
|
||||
model.provider !== "google-vertex" &&
|
||||
!extraBetas.includes(contextManagementBeta)
|
||||
) {
|
||||
extraBetas.push(contextManagementBeta);
|
||||
}
|
||||
|
||||
const created = createClient(model, {
|
||||
model,
|
||||
@@ -2944,11 +2961,28 @@ function buildParams(
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-compute context_management (depends on thinking).
|
||||
const contextManagement =
|
||||
isOAuthToken && thinking?.type === "adaptive"
|
||||
? { edits: [{ type: "clear_thinking_20251015" as const, keep: "all" as const }] }
|
||||
: undefined;
|
||||
// Pre-compute context_management. Send keep: "all" for every enabled or
|
||||
// adaptive thinking request (OAuth + API-key) — not just OAuth. Without
|
||||
// this directive Anthropic-compatible backends (Z.AI, Kimi, DeepSeek, …)
|
||||
// strip the replayed thinking blocks `replayUnsignedThinking` puts back
|
||||
// on the wire, so the model loses the prior reasoning chain across turns
|
||||
// and the KV cache misses every turn (#3288). Narrowing this guard back
|
||||
// to `isOAuthToken` regresses every API-key thinking provider. Skip
|
||||
// injected clients because this code cannot add the required
|
||||
// `context-management-2025-06-27` beta to caller-owned SDK clients. Skip
|
||||
// Copilot because its proxy strips Anthropic betas and demotes thinking
|
||||
// blocks to text upstream, so `keep: "all"` is a no-op that risks proxy
|
||||
// rejection of an unrecognized field. Skip Vertex rawPredict because that
|
||||
// adapter requires betas in the JSON body (`anthropic_beta`) instead of the
|
||||
// Anthropic HTTP beta header this code can add.
|
||||
const shouldKeepThinkingContext =
|
||||
!options?.client &&
|
||||
model.provider !== "github-copilot" &&
|
||||
model.provider !== "google-vertex" &&
|
||||
(thinking?.type === "adaptive" || thinking?.type === "enabled");
|
||||
const contextManagement = shouldKeepThinkingContext
|
||||
? { edits: [{ type: "clear_thinking_20251015" as const, keep: "all" as const }] }
|
||||
: undefined;
|
||||
|
||||
// Pre-compute output_config.
|
||||
const outputConfigEntries: AnthropicOutputConfig = {};
|
||||
|
||||
@@ -406,6 +406,41 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
expect(capturedBeta).toContain("mid-conversation-system-2026-04-07");
|
||||
});
|
||||
|
||||
it("adds the context-management beta to API-key thinking requests", async () => {
|
||||
let capturedBeta: string | undefined;
|
||||
const fetchMock = (async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
capturedBeta = (init?.headers as Record<string, string> | undefined)?.["anthropic-beta"];
|
||||
return new Response(
|
||||
JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: "captured" } }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
// `context_management.clear_thinking_20251015` is rejected without
|
||||
// the `context-management-2025-06-27` beta. OAuth requests carry it
|
||||
// via `claudeCodeAgentBetaDefaults`; API-key requests must add it
|
||||
// explicitly whenever thinking is enabled so the field is honored
|
||||
// instead of dropped on the floor (#3288).
|
||||
await streamAnthropic(
|
||||
ANTHROPIC_MODEL,
|
||||
{ systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }] },
|
||||
{ apiKey: "sk-ant-api-test", thinkingEnabled: true, fetch: fetchMock },
|
||||
).result();
|
||||
|
||||
expect(capturedBeta).toContain("context-management-2025-06-27");
|
||||
|
||||
capturedBeta = undefined;
|
||||
await streamAnthropic(
|
||||
ANTHROPIC_MODEL,
|
||||
{ systemPrompt: ["Stay concise."], messages: [{ role: "user", content: "Hi", timestamp: Date.now() }] },
|
||||
{ apiKey: "sk-ant-api-test", thinkingEnabled: false, fetch: fetchMock },
|
||||
).result();
|
||||
|
||||
// No context_management field is sent when thinking is disabled, so the
|
||||
// beta MUST NOT be advertised either.
|
||||
expect(capturedBeta ?? "").not.toContain("context-management-2025-06-27");
|
||||
});
|
||||
|
||||
it("billing-header fingerprint uses first user message, not leading developer message", async () => {
|
||||
const userText = "Hello from user with enough chars padding here";
|
||||
|
||||
@@ -1870,7 +1905,7 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
expect(maxPayload.output_config).toEqual({ effort: "max" });
|
||||
});
|
||||
|
||||
it("keeps summarized adaptive thinking by default for API-key Opus 4.7+ requests", async () => {
|
||||
it("keeps summarized adaptive thinking and context management for API-key Opus 4.7+ requests", async () => {
|
||||
const payload = (await captureAnthropicPayload(
|
||||
buildModel({
|
||||
...ANTHROPIC_MODEL_SPEC,
|
||||
@@ -1892,12 +1927,14 @@ describe("Anthropic request fingerprint alignment", () => {
|
||||
},
|
||||
)) as {
|
||||
thinking?: { type?: string; display?: string };
|
||||
context_management?: unknown;
|
||||
context_management?: { edits?: Array<{ type?: string; keep?: string | number }> };
|
||||
output_config?: { effort?: string };
|
||||
};
|
||||
|
||||
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
||||
expect(payload.context_management).toBeUndefined();
|
||||
expect(payload.context_management).toEqual({
|
||||
edits: [{ type: "clear_thinking_20251015", keep: "all" }],
|
||||
});
|
||||
expect(payload.output_config).toEqual({ effort: "xhigh" });
|
||||
});
|
||||
|
||||
|
||||
@@ -576,6 +576,36 @@ describe("anthropic stream envelope handling", () => {
|
||||
expect(capturedParams?.tools?.map(tool => tool.name)).toEqual(["web_search"]);
|
||||
expect(capturedOptions?.headers).toEqual({ "X-Umans-Websearch-Provider": "exa" });
|
||||
});
|
||||
|
||||
it("does not send context_management through injected clients", async () => {
|
||||
type CapturedPayload = {
|
||||
thinking?: { type?: string };
|
||||
context_management?: unknown;
|
||||
};
|
||||
let capturedParams: CapturedPayload | undefined;
|
||||
const client: AnthropicMessagesClientLike = {
|
||||
messages: {
|
||||
create(params) {
|
||||
capturedParams = params as CapturedPayload;
|
||||
return createMockRequest(createTextSuccessEvents("done"));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stream = streamAnthropic(model, context, {
|
||||
client,
|
||||
thinkingEnabled: true,
|
||||
});
|
||||
const events: AssistantMessageEvent[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
const result = await stream.result();
|
||||
|
||||
expect(result.content).toEqual([{ type: "text", text: "done" }]);
|
||||
expect(capturedParams?.thinking?.type).toBe("enabled");
|
||||
expect(capturedParams?.context_management).toBeUndefined();
|
||||
});
|
||||
it("unwraps thinking blocks that Anthropic streams with literal thinking tags", async () => {
|
||||
const wrappedThinking =
|
||||
"<thinking>\n<thinking>\nCheck logs before accepting container health.\n</thinking></thinking>";
|
||||
|
||||
@@ -101,6 +101,29 @@ describe("Anthropic-compatible unsigned thinking replay (#2005)", () => {
|
||||
expect(blocks[1]).toEqual({ type: "text", text: "Sure." });
|
||||
});
|
||||
|
||||
it("sends context_management for API-key Anthropic-compatible thinking requests", async () => {
|
||||
const { promise, resolve } = Promise.withResolvers<unknown>();
|
||||
streamAnthropic(
|
||||
makeModel(),
|
||||
{ systemPrompt: [], messages: [makeUser("continue")] },
|
||||
{
|
||||
apiKey: "sk-ant-api-test",
|
||||
signal: AbortSignal.abort(),
|
||||
thinkingEnabled: true,
|
||||
onPayload: payload => resolve(payload),
|
||||
},
|
||||
);
|
||||
|
||||
const payload = (await promise) as {
|
||||
thinking?: { type?: string };
|
||||
context_management?: { edits?: Array<{ type?: string; keep?: string }> };
|
||||
};
|
||||
expect(payload.thinking?.type).toBe("enabled");
|
||||
expect(payload.context_management).toEqual({
|
||||
edits: [{ type: "clear_thinking_20251015", keep: "all" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes lone surrogates in tool arguments regardless of origin API", () => {
|
||||
const loneSurrogate = "broken \ud83d end";
|
||||
const makeToolCallAssistant = (api: AssistantMessage["api"]): AssistantMessage => ({
|
||||
|
||||
@@ -56,9 +56,15 @@ describe("GitHub Copilot reasoning request construction", () => {
|
||||
const payload = (await captureAnthropicPayload(model)) as {
|
||||
thinking?: { type?: string };
|
||||
output_config?: { effort?: string };
|
||||
context_management?: unknown;
|
||||
};
|
||||
|
||||
expect(payload.thinking).toEqual({ type: "adaptive" });
|
||||
expect(payload.output_config).toEqual({ effort: "high" });
|
||||
// The Copilot Anthropic proxy strips Anthropic betas and demotes
|
||||
// thinking blocks to text upstream — the `context_management` field
|
||||
// would have no replayed thinking to keep and risks proxy rejection
|
||||
// of an unrecognized field. The field MUST NOT be sent (#3288).
|
||||
expect(payload.context_management).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -580,7 +580,12 @@ describe("Generate E2E Tests", () => {
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 64_000,
|
||||
});
|
||||
const captured = Promise.withResolvers<{ url: string; authorization: string | null; body: unknown }>();
|
||||
const captured = Promise.withResolvers<{
|
||||
url: string;
|
||||
authorization: string | null;
|
||||
betaHeader: string | null;
|
||||
body: unknown;
|
||||
}>();
|
||||
|
||||
try {
|
||||
__resetVertexTokenCache();
|
||||
@@ -598,6 +603,7 @@ describe("Generate E2E Tests", () => {
|
||||
{ messages: [{ role: "user", content: "Hello", timestamp: Date.now() }] },
|
||||
{
|
||||
apiKey: "<authenticated>",
|
||||
thinkingEnabled: true,
|
||||
fetch: async (input, init) => {
|
||||
const url = input instanceof Request ? input.url : input.toString();
|
||||
if (
|
||||
@@ -611,6 +617,7 @@ describe("Generate E2E Tests", () => {
|
||||
captured.resolve({
|
||||
url,
|
||||
authorization: headers.get("authorization"),
|
||||
betaHeader: headers.get("anthropic-beta"),
|
||||
body: JSON.parse(bodyText),
|
||||
});
|
||||
return new Response(JSON.stringify({ error: { message: "stop after capture" } }), { status: 400 });
|
||||
@@ -632,6 +639,9 @@ describe("Generate E2E Tests", () => {
|
||||
stream: true,
|
||||
});
|
||||
expect((request.body as Record<string, unknown>).model).toBeUndefined();
|
||||
expect((request.body as Record<string, { type?: string }>).thinking?.type).toBe("enabled");
|
||||
expect((request.body as Record<string, unknown>).context_management).toBeUndefined();
|
||||
expect(request.betaHeader ?? "").not.toContain("context-management-2025-06-27");
|
||||
} finally {
|
||||
__resetVertexTokenCache();
|
||||
homedirSpy.mockRestore();
|
||||
|
||||
Reference in New Issue
Block a user