feat(agent-core): enable Preserved Thinking by default on the Anthropic provider (#1432)

* feat(agent-core): enable Preserved Thinking by default on the Anthropic provider

Default thinking.keep to "all" for the Anthropic provider (Claude and Kimi in Anthropic-compatible mode) while Thinking is on, via a context_management clear_thinking_20251015 edit, mirroring the Kimi default. Reuses [thinking] keep and KIMI_MODEL_THINKING_KEEP (env > config > default "all"); off-values disable it.

* feat(kosong): route Anthropic Preserved Thinking through the beta Messages API

Force the beta endpoint (client.beta.messages.create) when thinking.keep is enabled, since clear_thinking_20251015 is only honored there. Also prepend clear_thinking to any existing context-management edits (for example clear_tool_uses) instead of replacing them, keeping it first as Anthropic requires when combining edits.

* docs: clarify Anthropic beta endpoint and compaction keep behavior

Note in code comments and bilingual docs that enabling Anthropic Preserved Thinking routes requests to the beta Messages API (client.beta.messages.create), with keep=off as the escape hatch back to the standard endpoint. Correct the resolveThinkingKeep comment to reflect that compaction shares ConfigState.provider and intentionally carries the same keep.

* test(kosong): cover Anthropic beta endpoint (streaming and forced betaApi)

Add a streaming beta-endpoint capture and a test that withThinkingKeep forces the beta endpoint even when constructed with betaApi: false, pinning down the documented behavior.
This commit is contained in:
Kai 2026-07-06 23:45:17 +08:00 committed by GitHub
parent 379bc57ef0
commit 25a655cf88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 365 additions and 13 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---
Preserve prior turns' thinking by default on the Anthropic provider (Claude and Kimi's Anthropic-compatible mode), matching the Kimi default. Disable with `[thinking] keep = "off"` or `KIMI_MODEL_THINKING_KEEP=off`.

View file

@ -169,7 +169,7 @@ You can also switch models temporarily without touching the config file — by s
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | Whether Thinking is enabled by default for new sessions; set to `false` to force Thinking off |
| `effort` | `string` | — | Thinking effort level (for example `low`, `medium`, `high`, `xhigh`, `max`); the levels actually available depend on the model's declared `support_efforts`, and unrecognized values are ignored by the provider |
| `keep` | `string` | `"all"` | Moonshot Preserved Thinking passthrough (`thinking.keep`). `"all"` preserves previous turns' `reasoning_content`; set to an off-value (`false`/`0`/`no`/`off`/`none`/`null`) to disable. Overridden by `KIMI_MODEL_THINKING_KEEP`; applies to the `kimi` provider only, and only while Thinking is on |
| `keep` | `string` | `"all"` | Preserved Thinking passthrough. On `kimi` it is sent as `thinking.keep`; on `anthropic` (Claude and Kimi's Anthropic-compatible mode) it is sent as a `context_management` `clear_thinking_20251015` edit (enabling keep routes Anthropic requests to the beta Messages API; an off-value disables keep and returns to the standard endpoint). `"all"` preserves prior turns' reasoning (`reasoning_content` / Anthropic thinking blocks); set to an off-value (`false`/`0`/`no`/`off`/`none`/`null`) to disable. Overridden by `KIMI_MODEL_THINKING_KEEP`; only injected while Thinking is on |
### Deprecated fields

View file

@ -130,7 +130,7 @@ Switches that control the behavior of subsystems such as telemetry, background t
| `KIMI_MODEL_TEMPERATURE` | Sampling temperature for every request; applies to the `kimi` provider only (global — independent of `KIMI_MODEL_NAME`) | Number, e.g. `0.3` |
| `KIMI_MODEL_TOP_P` | Nucleus-sampling `top_p` for every request; applies to the `kimi` provider only (global) | Number, e.g. `0.95` |
| `KIMI_MODEL_THINKING_EFFORT` | Force a specific thinking effort on the wire (`thinking.effort`), bypassing the model's declared `support_efforts`; applies to the `kimi` provider only, and only while Thinking is on | An effort value, e.g. `max` |
| `KIMI_MODEL_THINKING_KEEP` | Moonshot preserved-thinking passthrough (`thinking.keep`); overrides `[thinking] keep` (which defaults to `"all"`); applies to the `kimi` provider only, and only while Thinking is on | A value the API accepts, e.g. `all`; an off-value (`false`/`0`/`no`/`off`/`none`/`null`) disables it |
| `KIMI_MODEL_THINKING_KEEP` | Preserved-thinking passthrough; on `kimi` sent as `thinking.keep`, on `anthropic` (Claude and Kimi's Anthropic-compatible mode) sent as a `context_management` `clear_thinking_20251015` edit (enabling keep routes Anthropic requests to the beta Messages API); overrides `[thinking] keep` (which defaults to `"all"`); only injected while Thinking is on | A value the API accepts, e.g. `all`; an off-value (`false`/`0`/`no`/`off`/`none`/`null`) disables it |
| `KIMI_CODE_NO_AUTO_UPDATE` | Fully disable the update preflight — no check, background install, or prompt. Legacy alias `KIMI_CLI_NO_AUTO_UPDATE` is also honored | Truthy: `1`/`true`/`yes`/`on` |
| `KIMI_DISABLE_CRON` | Disable the scheduled-task tool (`CronCreate` rejects new schedules; existing tasks do not fire) | `1` to disable |

View file

@ -169,7 +169,7 @@ display_name = "Kimi for Coding (custom)"
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | 新会话是否默认开启 Thinking设为 `false` 可强制关闭 |
| `effort` | `string` | — | Thinking 强度(例如 `low``medium``high``xhigh``max`),实际可用等级取决于模型声明的 `support_efforts`,未识别的值会被供应商忽略 |
| `keep` | `string` | `"all"` | Moonshot 保留思考透传(`thinking.keep`)。`"all"` 会保留历史轮次的 `reasoning_content`;传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用。可被 `KIMI_MODEL_THINKING_KEEP` 覆盖;仅`kimi` 供应商生效,且仅在 Thinking 开启时注入 |
| `keep` | `string` | `"all"` | 保留思考透传。在 `kimi` 上以 `thinking.keep` 发送;在 `anthropic`Claude 以及 Kimi 的 Anthropic 兼容模式)上以 `context_management``clear_thinking_20251015` 编辑发送(开启 keep 会让 Anthropic 请求走 beta Messages API关值可禁用 keep 并回到标准端点)。`"all"` 会保留历史轮次的思考内容(`reasoning_content` / Anthropic thinking blocks;传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用。可被 `KIMI_MODEL_THINKING_KEEP` 覆盖;仅在 Thinking 开启时注入 |
### 已废弃字段

View file

@ -130,7 +130,7 @@ kimi
| `KIMI_MODEL_TEMPERATURE` | 每次请求的采样温度,仅对 `kimi` 供应商生效(全局生效,不依赖 `KIMI_MODEL_NAME` | 数字,如 `0.3` |
| `KIMI_MODEL_TOP_P` | 每次请求的核采样 `top_p`,仅对 `kimi` 供应商生效(全局生效) | 数字,如 `0.95` |
| `KIMI_MODEL_THINKING_EFFORT` | 在线上强制使用指定的思考强度(`thinking.effort`),绕过模型声明的 `support_efforts`;仅对 `kimi` 供应商生效,且仅在 Thinking 开启时注入 | 思考强度值,如 `max` |
| `KIMI_MODEL_THINKING_KEEP` | Moonshot 保留思考透传(`thinking.keep`);覆盖 `[thinking] keep`(其默认值为 `"all"`);仅`kimi` 供应商生效,且仅在 Thinking 开启时注入 | API 接受的值,如 `all`;传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用 |
| `KIMI_MODEL_THINKING_KEEP` | 保留思考透传;在 `kimi` 上以 `thinking.keep` 发送,在 `anthropic`Claude 以及 Kimi 的 Anthropic 兼容模式)上以 `context_management``clear_thinking_20251015` 编辑发送(开启 keep 会让 Anthropic 请求走 beta Messages API);覆盖 `[thinking] keep`(其默认值为 `"all"`);仅在 Thinking 开启时注入 | API 接受的值,如 `all`;传入关值(`false`/`0`/`no`/`off`/`none`/`null`)可禁用 |
| `KIMI_CODE_NO_AUTO_UPDATE` | 完全禁用更新预检——不检查、不后台安装、不提示。同时兼容旧名 `KIMI_CLI_NO_AUTO_UPDATE` | 真值:`1`/`true`/`yes`/`on` |
| `KIMI_DISABLE_CRON` | 禁用定时任务工具(`CronCreate` 拒绝新计划,已有任务不触发) | `1` 表示禁用 |

View file

@ -7,6 +7,7 @@ import {
} from '@moonshot-ai/kosong';
import {
applyAnthropicThinkingKeep,
applyKimiEnvSamplingParams,
applyKimiEnvThinkingEffort,
applyKimiEnvThinkingKeep,
@ -122,16 +123,20 @@ export class ConfigState {
// - withThinking: preserve thinking during compaction (#464)
// - sampling params: KIMI_MODEL_TEMPERATURE / KIMI_MODEL_TOP_P
// - thinking.effort: KIMI_MODEL_THINKING_EFFORT (forces an effort, only while thinking is on)
// - thinking.keep: env KIMI_MODEL_THINKING_KEEP > config thinking.keep > default "all" (only while thinking is on)
// - thinking.keep: env KIMI_MODEL_THINKING_KEEP > config thinking.keep > default "all"
// (only while thinking is on). Drives Kimi's `thinking.keep` and, on the
// Anthropic path, a `context_management` `clear_thinking_20251015` edit.
const provider = createProvider(this.providerConfig).withThinking(this.thinkingEffort);
const withSampling = applyKimiEnvSamplingParams(provider);
const withEffort = applyKimiEnvThinkingEffort(withSampling, this.thinkingEffort);
return applyKimiEnvThinkingKeep(
const configKeep = this.agent.kimiConfig?.thinking?.keep;
const withKimiKeep = applyKimiEnvThinkingKeep(
withEffort,
this.thinkingEffort,
undefined,
this.agent.kimiConfig?.thinking?.keep,
configKeep,
);
return applyAnthropicThinkingKeep(withKimiKeep, this.thinkingEffort, undefined, configKeep);
}
get model(): string {

View file

@ -4,6 +4,7 @@ import {
KimiChatProvider,
type ThinkingEffort,
} from '@moonshot-ai/kosong';
import { AnthropicChatProvider } from '@moonshot-ai/kosong/providers/anthropic';
import { parseFloatEnv } from '#/config/resolve';
@ -77,12 +78,16 @@ function parseKeepValue(raw: string | undefined): KeepResolution {
}
/**
* Resolve the Moonshot Preserved Thinking passthrough (`thinking.keep`) with
* precedence env (`KIMI_MODEL_THINKING_KEEP`) > config (`thinking.keep`) >
* default `"all"`. Only meaningful while thinking is on otherwise the API
* would receive a `thinking.keep` with no accompanying `thinking.type` it
* honors. (Compaction uses a raw provider with thinking off, so it correctly
* resolves to `undefined`.)
* Resolve the Preserved Thinking passthrough (Kimi `thinking.keep` / Anthropic
* `context_management` `clear_thinking_20251015`) with precedence env
* (`KIMI_MODEL_THINKING_KEEP`) > config (`thinking.keep`) > default `"all"`.
* Only meaningful while thinking is on otherwise the API would receive a keep
* directive with no accompanying `thinking.type` it honors, so it resolves to
* `undefined`. Applied via `ConfigState.provider`, which is shared by the main
* loop AND full-history compaction, so compaction intentionally carries the
* same keep (and, for Anthropic, the beta endpoint) when thinking is on;
* `keep:"all"` prunes nothing and a consistent request shape maximizes KV-cache
* reuse.
*
* Returns `undefined` when Preserved Thinking should be disabled.
*/
@ -115,3 +120,24 @@ export function applyKimiEnvThinkingKeep(
if (keep === undefined) return provider;
return provider.withExtraBody({ thinking: { keep } });
}
/**
* Apply the Anthropic equivalent of Preserved Thinking a `context_management`
* `clear_thinking_20251015` edit carrying `keep` to an Anthropic chat
* provider. See `resolveThinkingKeep` for precedence. Non-Anthropic providers
* are returned unchanged. Applies to every Anthropic provider (Claude and
* Kimi's Anthropic-compatible mode) while thinking is on; `keep: "all"` tells
* the server to retain all prior thinking blocks (prune none), mirroring Kimi's
* `thinking.keep`.
*/
export function applyAnthropicThinkingKeep(
provider: ChatProvider,
thinkingEffort: ThinkingEffort,
env: Env = process.env,
configKeep?: string,
): ChatProvider {
if (!(provider instanceof AnthropicChatProvider)) return provider;
const keep = resolveThinkingKeep(env, configKeep, thinkingEffort);
if (keep === undefined) return provider;
return provider.withThinkingKeep(keep);
}

View file

@ -398,4 +398,58 @@ describe('ConfigState.provider applies global KIMI_MODEL_* request config', () =
vi.unstubAllEnvs();
}
});
function anthropicAgentWithThinkingKeep(keep: string | undefined) {
const config: KimiConfig = {
providers: { anthropic: { type: 'anthropic', apiKey: 'test-key' } },
models: {
'claude-sonnet-4-6': {
provider: 'anthropic',
model: 'claude-sonnet-4-6',
maxContextSize: 200_000,
capabilities: ['thinking', 'tool_use'],
},
},
...(keep !== undefined ? { thinking: { keep } } : {}),
};
return testAgent({
initialConfig: config,
providerManager: new ProviderManager({ config }),
});
}
it('injects context_management clear_thinking keep into config.provider for anthropic when thinking is on', () => {
vi.stubEnv('KIMI_MODEL_THINKING_KEEP', 'all');
try {
const ctx = anthropicAgentWithThinkingKeep(undefined);
ctx.agent.config.update({ modelAlias: 'claude-sonnet-4-6', thinkingEffort: 'high' });
const provider = ctx.agent.config.provider;
const gen = Reflect.get(provider as object, '_generationKwargs') as {
contextManagement?: { edits: Array<{ type: string; keep?: string }> };
betaFeatures?: string[];
};
expect(gen.contextManagement).toEqual({
edits: [{ type: 'clear_thinking_20251015', keep: 'all' }],
});
expect(gen.betaFeatures).toContain('context-management-2025-06-27');
} finally {
vi.unstubAllEnvs();
}
});
it('does NOT inject context_management for anthropic when thinking is off', () => {
vi.stubEnv('KIMI_MODEL_THINKING_KEEP', 'all');
try {
const ctx = anthropicAgentWithThinkingKeep(undefined);
ctx.agent.config.update({ modelAlias: 'claude-sonnet-4-6', thinkingEffort: 'off' });
const gen = Reflect.get(ctx.agent.config.provider as object, '_generationKwargs') as {
contextManagement?: unknown;
};
expect(gen.contextManagement).toBeUndefined();
} finally {
vi.unstubAllEnvs();
}
});
});

View file

@ -1,7 +1,9 @@
import { type ChatProvider, KimiChatProvider } from '@moonshot-ai/kosong';
import { AnthropicChatProvider } from '@moonshot-ai/kosong/providers/anthropic';
import { describe, expect, it } from 'vitest';
import {
applyAnthropicThinkingKeep,
applyKimiEnvSamplingParams,
applyKimiEnvThinkingEffort,
applyKimiEnvThinkingKeep,
@ -151,3 +153,94 @@ describe('applyKimiEnvThinkingEffort', () => {
).toBe(stub);
});
});
function anthropic(): AnthropicChatProvider {
return new AnthropicChatProvider({ model: 'claude-sonnet-4-6', apiKey: 'k' });
}
interface AnthropicKeepState {
contextManagement?: { edits: Array<{ type: string; keep?: string }> };
betaFeatures?: string[];
}
function anthropicState(provider: ChatProvider): AnthropicKeepState {
return Reflect.get(provider as object, '_generationKwargs') as AnthropicKeepState;
}
describe('applyAnthropicThinkingKeep', () => {
it('injects context_management keep="all" by default when thinking is on', () => {
const out = applyAnthropicThinkingKeep(anthropic(), 'high', {});
expect(anthropicState(out).contextManagement).toEqual({
edits: [{ type: 'clear_thinking_20251015', keep: 'all' }],
});
expect(anthropicState(out).betaFeatures).toContain('context-management-2025-06-27');
});
it('injects keep from env when thinking is on', () => {
const out = applyAnthropicThinkingKeep(anthropic(), 'high', { KIMI_MODEL_THINKING_KEEP: 'all' });
expect(anthropicState(out).contextManagement?.edits[0]?.keep).toBe('all');
});
it('injects keep from config when env is unset', () => {
const out = applyAnthropicThinkingKeep(anthropic(), 'high', {}, 'all');
expect(anthropicState(out).contextManagement?.edits[0]?.keep).toBe('all');
});
it('env takes precedence over config', () => {
const out = applyAnthropicThinkingKeep(
anthropic(),
'high',
{ KIMI_MODEL_THINKING_KEEP: 'all' },
'off',
);
expect(anthropicState(out).contextManagement?.edits[0]?.keep).toBe('all');
});
it.each(['off', 'false', '0', 'no', 'none', 'null', 'OFF', 'None'])(
'env off-value %s disables keep even when config enables it',
(off) => {
const out = applyAnthropicThinkingKeep(
anthropic(),
'high',
{ KIMI_MODEL_THINKING_KEEP: off },
'all',
);
expect(anthropicState(out).contextManagement).toBeUndefined();
},
);
it.each(['off', 'none', 'null'])('config off-value %s disables keep by default', (off) => {
const out = applyAnthropicThinkingKeep(anthropic(), 'high', {}, off);
expect(anthropicState(out).contextManagement).toBeUndefined();
});
it('blank env falls through to config', () => {
const out = applyAnthropicThinkingKeep(
anthropic(),
'high',
{ KIMI_MODEL_THINKING_KEEP: ' ' },
'off',
);
expect(anthropicState(out).contextManagement).toBeUndefined();
});
it('does NOT inject context_management when thinking is off', () => {
const out = applyAnthropicThinkingKeep(anthropic(), 'off', { KIMI_MODEL_THINKING_KEEP: 'all' });
expect(anthropicState(out).contextManagement).toBeUndefined();
});
it('does not duplicate the context-management beta on repeated calls', () => {
const out = applyAnthropicThinkingKeep(
applyAnthropicThinkingKeep(anthropic(), 'high', {}),
'high',
{},
);
const betas = anthropicState(out).betaFeatures ?? [];
expect(betas.filter((b) => b === 'context-management-2025-06-27')).toHaveLength(1);
});
it('leaves non-anthropic providers untouched', () => {
const stub = { name: 'stub' } as unknown as ChatProvider;
expect(applyAnthropicThinkingKeep(stub, 'high', { KIMI_MODEL_THINKING_KEEP: 'all' })).toBe(stub);
});
});

View file

@ -114,6 +114,17 @@ interface AnthropicGenerationKwargs {
thinking?: MessageCreateParams['thinking'] | undefined;
output_config?: MessageCreateParams['output_config'] | undefined;
betaFeatures?: string[] | undefined;
contextManagement?: AnthropicContextManagement | undefined;
}
/**
* Anthropic beta context-management payload (`context-management-2025-06-27`).
* Only the `clear_thinking_20251015` edit is emitted today, with `keep`
* forwarded as a string (`"all"`); the `{ type, value }` turn-count form is
* not used because the shared `[thinking] keep` config is a string.
*/
interface AnthropicContextManagement {
edits: Array<{ type: string; keep?: unknown }>;
}
// Anthropic's native effort values. `ThinkingEffort` is an open string, so after
@ -122,6 +133,8 @@ interface AnthropicGenerationKwargs {
type AnthropicEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14';
const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27';
const CLEAR_THINKING_EDIT = 'clear_thinking_20251015';
const OPUS_VERSION_RE = /opus[.-](\d+)[.-](\d{1,2})(?!\d)/;
const ADAPTIVE_MIN_VERSION = { major: 4, minor: 6 } as const;
const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = {
@ -1083,6 +1096,9 @@ export class AnthropicChatProvider implements ChatProvider {
if (this._generationKwargs.output_config !== undefined) {
kwargs['output_config'] = this._generationKwargs.output_config;
}
if (this._generationKwargs.contextManagement !== undefined) {
kwargs['context_management'] = this._generationKwargs.contextManagement;
}
// Build the beta feature list. On the standard Messages API these travel
// via the `anthropic-beta` header; on the beta Messages API (`betaApi`) the
@ -1289,6 +1305,35 @@ export class AnthropicChatProvider implements ChatProvider {
return clone;
}
withThinkingKeep(keep: string): AnthropicChatProvider {
const current = this._generationKwargs.betaFeatures ?? [];
const betaFeatures = current.includes(CONTEXT_MANAGEMENT_BETA)
? current
: [...current, CONTEXT_MANAGEMENT_BETA];
// Preserve any existing context-management edits (e.g. clear_tool_uses) and
// keep clear_thinking first, as Anthropic requires when combining edits. Drop
// a previous clear_thinking edit so re-applying stays idempotent.
const existingEdits = this._generationKwargs.contextManagement?.edits ?? [];
const edits = [
{ type: CLEAR_THINKING_EDIT, keep },
...existingEdits.filter((edit) => edit.type !== CLEAR_THINKING_EDIT),
];
const clone = this._withGenerationKwargs({
contextManagement: { edits },
betaFeatures,
});
// clear_thinking_20251015 is honored only on the beta Messages API
// (client.beta.messages.create), so enabling keep forces the beta endpoint
// here even when the provider was constructed with betaApi: false. Setting
// `[thinking] keep` to an off-value (or KIMI_MODEL_THINKING_KEEP=off) is the
// escape hatch that disables keep and returns requests to the standard
// endpoint. This also routes adaptive models (whose withThinking would
// otherwise drop the interleaved-thinking beta and leave betaFeatures empty)
// onto the beta endpoint with a body `betas=[context-management-...]`.
clone._betaApi = true;
return clone;
}
withGenerationKwargs(kwargs: Partial<AnthropicGenerationKwargs>): AnthropicChatProvider {
return this._withGenerationKwargs(kwargs);
}

View file

@ -50,6 +50,7 @@ type AnthropicGenerationState = {
| undefined;
output_config?: { effort: string } | undefined;
betaFeatures?: string[] | undefined;
contextManagement?: { edits: Array<{ type: string; keep?: unknown }> } | undefined;
};
function getGenerationState(provider: AnthropicChatProvider): AnthropicGenerationState {
@ -218,6 +219,129 @@ describe('betaApi', () => {
});
});
describe('withThinkingKeep (context_management)', () => {
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
];
it('forces the beta endpoint and emits context_management clear_thinking keep with the context-management beta', async () => {
// betaApi is left at its default (false); withThinkingKeep must force it on.
const provider = createProvider().withThinkingKeep('all');
const body = await captureBetaRequestBody(provider, '', [], history);
expect(body['context_management']).toEqual({
edits: [{ type: 'clear_thinking_20251015', keep: 'all' }],
});
expect(body['betas']).toContain('context-management-2025-06-27');
expect(body['betas']).toContain('interleaved-thinking-2025-05-14');
const headers = body['_extra_headers'] as Record<string, string> | undefined;
expect(headers?.['anthropic-beta']).toBeUndefined();
});
it('prepends clear_thinking before existing context-management edits and keeps them', () => {
const provider = createProvider()
.withGenerationKwargs({
contextManagement: {
edits: [{ type: 'clear_tool_uses_20250919', keep: { type: 'tool_uses', value: 2 } }],
},
})
.withThinkingKeep('all');
const state = getGenerationState(provider);
expect(state.contextManagement).toEqual({
edits: [
{ type: 'clear_thinking_20251015', keep: 'all' },
{ type: 'clear_tool_uses_20250919', keep: { type: 'tool_uses', value: 2 } },
],
});
});
it('emits no context_management and stays off the beta endpoint when withThinkingKeep is not applied', async () => {
const body = await captureRequestBody(createProvider(), '', [], history);
expect(body['context_management']).toBeUndefined();
const headers = body['_extra_headers'] as Record<string, string> | undefined;
expect(headers?.['anthropic-beta']).not.toContain('context-management-2025-06-27');
});
it('does not duplicate the context-management beta or the clear_thinking edit across repeated calls', () => {
const provider = createProvider().withThinkingKeep('all').withThinkingKeep('all');
const state = getGenerationState(provider);
const betas = state.betaFeatures ?? [];
expect(betas.filter((b) => b === 'context-management-2025-06-27')).toHaveLength(1);
expect(state.contextManagement).toEqual({
edits: [{ type: 'clear_thinking_20251015', keep: 'all' }],
});
});
// Capture a streaming (stream: true) beta-endpoint request by mocking
// client.beta.messages.create to return a minimal valid stream.
async function captureBetaStreamBody(
provider: AnthropicChatProvider,
history: Message[],
): Promise<Record<string, unknown>> {
let capturedParams: Record<string, unknown> | undefined;
let capturedOptions: Record<string, unknown> | undefined;
(provider as any)._client.beta.messages.create = vi
.fn()
.mockImplementation((params: unknown, options?: unknown) => {
capturedParams = params as Record<string, unknown>;
capturedOptions = options as Record<string, unknown> | undefined;
return Promise.resolve(
mockStream([
{
type: 'message_start',
message: { id: 'm', usage: { input_tokens: 1, output_tokens: 0 } },
},
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'ok' } },
{ type: 'content_block_stop', index: 0 },
{
type: 'message_delta',
delta: { stop_reason: 'end_turn' },
usage: { output_tokens: 1 },
},
{ type: 'message_stop' },
]),
);
});
const stream = await provider.generate('', [], history);
for await (const part of stream) void part;
if (capturedParams === undefined) {
throw new Error('Expected provider.generate() to call beta.messages.create');
}
const result = { ...capturedParams };
if (capturedOptions !== undefined && capturedOptions['headers'] !== undefined) {
result['_extra_headers'] = capturedOptions['headers'];
}
return result;
}
it('emits context_management on the streaming beta endpoint too', async () => {
const provider = createStreamProvider().withThinkingKeep('all');
const body = await captureBetaStreamBody(provider, history);
expect(body['context_management']).toEqual({
edits: [{ type: 'clear_thinking_20251015', keep: 'all' }],
});
expect(body['betas']).toContain('context-management-2025-06-27');
const headers = body['_extra_headers'] as Record<string, string> | undefined;
expect(headers?.['anthropic-beta']).toBeUndefined();
});
it('forces the beta endpoint even when constructed with betaApi: false', async () => {
const provider = new AnthropicChatProvider({
model: 'kimi-for-coding',
apiKey: 'test-key',
defaultMaxTokens: 1024,
stream: false,
betaApi: false,
}).withThinkingKeep('all');
const body = await captureBetaRequestBody(provider, '', [], history);
expect(body['context_management']).toEqual({
edits: [{ type: 'clear_thinking_20251015', keep: 'all' }],
});
expect(body['betas']).toContain('context-management-2025-06-27');
});
});
describe('AnthropicChatProvider', () => {
it('does not read ANTHROPIC_API_KEY from process.env inside the adapter', () => {
const previousApiKey = process.env['ANTHROPIC_API_KEY'];