diff --git a/.changeset/catalog-followup-correctness.md b/.changeset/catalog-followup-correctness.md index 569e577c844..cd9cc93994e 100644 --- a/.changeset/catalog-followup-correctness.md +++ b/.changeset/catalog-followup-correctness.md @@ -3,7 +3,8 @@ "@moonshot-ai/kimi-code-sdk": patch "@moonshot-ai/agent-core": patch "@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kap-server": patch "@moonshot-ai/kimi-code": patch --- -Fix a set of small correctness issues on top of the catalog metadata work: configured efforts (config or the KIMI_MODEL_THINKING_EFFORT env override) are now normalized instead of being sent upstream as invalid values; a model's declared input limit can no longer exceed its effective context window, and the clamp now copies the record instead of mutating the user's config in place; context-usage percentages share one denominator (the effective input cap) across status endpoints, clamped to 1 where the wire schema bounds it while event streams keep the documented raw overflow signal; a provider-observed smaller context window now actually wins over the catalog's declared input cap during overflow recovery; per-model endpoints declared with an unrecognized override SDK are preserved via the OpenAI-compatible fallback, while known proprietary SDKs stay refused; and the model inspector attributes input-limit fields to their actual config, override, or clamp provenance. +Fix catalog-imported Claude models being wrongly locked into always-on thinking, and stop offering a misleading thinking Off option for models that cannot truly disable reasoning (such as Gemini 3). Also normalizes configured thinking effort values and unifies context-usage reporting. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index bc99820f774..b29cf7f2e48 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -149,10 +149,13 @@ Each entry in the `models` table defines a model alias (the name used in `defaul | `provider` | `string` | Yes | Name of the provider to use; must be defined in `providers` | | `model` | `string` | Yes | Model identifier sent to the server when calling the API | | `max_context_size` | `integer` | Yes | Maximum context length in tokens; must be at least 1 | +| `max_input_size` | `integer` | No | Declared per-request input limit when it sits below the total window (e.g. gpt-5: 400k window, 272k input). Compaction, context-overflow checks, and usage ratios prefer it; completion budgeting keeps the total window. Resolution clamps it to `max_context_size` | | `max_output_size` | `integer` | No | Per-request output token cap (maps to `max_tokens`). Currently only the `anthropic` provider honors it. When set for a Claude model, this explicit value overrides the built-in server-side maximum | | `capabilities` | `array` | No | Capability tags to add explicitly: `thinking`, `always_thinking`, `image_in`, `video_in`, `audio_in`, `tool_use`. Unioned with the capabilities auto-detected by the provider — entries can only be added, never removed | | `support_efforts` | `array` | No | Thinking effort levels the model accepts. For `kimi`, selecting another value at runtime fails; when model resolution carries an unsupported configured or previous value, the session falls back to the target model's `default_effort` and reports that effective value to the UI. A Thinking-capable Kimi model without this field uses boolean `on` / `off`. Other providers pass concrete values unchanged when their protocol has a native effort field; protocols that expose only levels or token budgets perform the required format conversion. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."".overrides] support_efforts` instead | | `default_effort` | `string` | No | Default thinking effort for the model. Managed and open-platform refreshes may rewrite this field; to pin it manually, set `[models."".overrides] default_effort` instead | +| `off_effort` | `string` | No | Effort value sent on the wire to disable thinking (e.g. `none` for xai grok). Only meaningful for models that declare such an encoding (catalog imports set it): turning thinking Off then sends this value instead of omitting the effort field — the only way to actually stop reasoning on models that reason by default | +| `base_url` | `string` | No | Per-model endpoint override (written by catalog imports for gateway models served away from the provider default). Resolution prefers it over the provider's `base_url`; only takes effect together with `protocol` | | `display_name` | `string` | No | Name shown in the UI; falls back to `model` when unset | | `reasoning_key` | `string` | No | `openai` provider only. Override the field name used for reasoning content when the gateway returns it under a non-standard name; by default `reasoning_content`, `reasoning_details`, and `reasoning` are auto-detected | | `adaptive_thinking` | `boolean` | No | `anthropic` provider only. Force adaptive thinking on or off, overriding the version inference based on the model name. Omit to infer automatically (Claude ≥ 4.6 uses adaptive) | @@ -181,7 +184,7 @@ max_context_size = 131072 display_name = "Kimi for Coding (custom)" ``` -`[models."".overrides]` accepts ordinary model fields such as `max_context_size`, `max_output_size`, `capabilities`, `display_name`, `reasoning_key`, `adaptive_thinking`, `support_efforts`, and `default_effort`. It does not accept identity / routing fields: `provider`, `model`, `protocol`, `beta_api`, and `base_url`. +`[models."".overrides]` accepts ordinary model fields such as `max_context_size`, `max_input_size`, `max_output_size`, `capabilities`, `display_name`, `reasoning_key`, `adaptive_thinking`, `support_efforts`, `default_effort`, and `off_effort`. It does not accept identity / routing fields: `provider`, `model`, `protocol`, `beta_api`, and `base_url`. You can also switch models temporarily without touching the config file — by setting `KIMI_MODEL_*` environment variables, the CLI synthesizes a temporary provider in memory that does not persist after restart. See [Define a model from environment variables](./env-vars.md#define-a-model-from-environment-variables-kimi_model). diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md index de42925885b..7ba39009e09 100644 --- a/docs/en/configuration/providers.md +++ b/docs/en/configuration/providers.md @@ -31,7 +31,7 @@ The manager displays providers as a list of entries grouped by source. Navigatio Two paths when adding: -- **Known third-party provider**: fetches the model catalog from [models.dev](https://models.dev/), select a provider → enter an API key → select a default model +- **Known third-party provider**: fetches the model catalog from [models.dev](https://models.dev/), select a provider → enter an API key → select a default model. Vendors whose protocol the catalog does not declare (e.g. xai, openrouter, and other vendor-specific SDKs) are imported as OpenAI-compatible with a "guessed" note; when the catalog provides no usable endpoint, a base URL prompt appears first; proprietary protocols (Amazon Bedrock, Cohere) and unrecognized explicit protocols are refused. Deprecated and alpha-status models are excluded from the import list - **Custom registry (api.json)**: paste a custom registry URL and Bearer token; the CLI automatically creates the `providers` / `models` entries. On later startup, providers from the same registry URL are refreshed together, so upstream provider additions, removals, and model metadata changes are synced. ::: warning diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 932d9283de5..fbc68620d0e 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -149,10 +149,13 @@ KIMI_BASE_URL = "https://api.moonshot.ai/v1" | `provider` | `string` | 是 | 使用的供应商名称,必须在 `providers` 中定义 | | `model` | `string` | 是 | 调用 API 时实际传给服务端的模型 ID | | `max_context_size` | `integer` | 是 | 最大上下文长度(token 数),必须 ≥ 1 | +| `max_input_size` | `integer` | 否 | 模型声明的单次请求输入上限(当低于总窗口时,如 gpt-5 的 400k 窗口 / 272k 输入)。压缩、上下文溢出检查和用量比率优先使用它;补全预算仍使用总窗口。解析时会被钳制到不超过 `max_context_size` | | `max_output_size` | `integer` | 否 | 单次请求的输出 token 上限(对应 `max_tokens`)。目前仅 `anthropic` 供应商读取。为 Claude 模型设置后,这个显式值会覆盖内置的服务端最大值 | | `capabilities` | `array` | 否 | 显式追加的能力标签:`thinking`、`always_thinking`、`image_in`、`video_in`、`audio_in`、`tool_use`。与供应商自动识别的能力取并集,只能追加不能移除 | | `support_efforts` | `array` | 否 | 模型接受的 Thinking 档位。对 `kimi` 而言,在运行时选择列表外的值会报错;模型解析时若配置值或之前的值不受目标模型支持,会回落到目标模型的 `default_effort`,并将该有效值同步给 UI。支持 Thinking 但没有此字段的 Kimi 模型使用布尔 `on` / `off`。其他 provider 在协议提供原生 effort 字段时会原样传递具体值;协议仅提供等级或 token budget 时,只做必要的格式转换。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."".overrides] support_efforts` | | `default_effort` | `string` | 否 | 模型的默认 Thinking 档位。managed 和 open-platform 刷新可能会改写该字段;如需手动固定,请改用 `[models."".overrides] default_effort` | +| `off_effort` | `string` | 否 | 关闭 Thinking 时在线上传输的 effort 编码(如 xai grok 的 `none`)。仅对声明了该编码的模型(catalog 会导入)有意义:设置后选择 Off 会发送这个值而不是省略 effort 字段——对默认就会推理的模型,这是真正关闭推理的唯一方式 | +| `base_url` | `string` | 否 | 模型级端点覆盖(catalog 导入网关模型时写入,这些模型与供应商默认端点不同)。解析时优先于供应商的 `base_url`;仅在与 `protocol` 配合时生效 | | `display_name` | `string` | 否 | UI 中显示的名称,未设时回退到 `model` | | `reasoning_key` | `string` | 否 | 仅 `openai` 供应商。当网关用非标准字段名返回推理内容时才需要设置;默认自动识别 `reasoning_content` / `reasoning_details` / `reasoning` | | `adaptive_thinking` | `boolean` | 否 | 仅 `anthropic` 供应商。强制开启或关闭 adaptive thinking,覆盖按模型名推断的逻辑。省略时自动推断(Claude ≥ 4.6 使用 adaptive) | @@ -181,7 +184,7 @@ max_context_size = 131072 display_name = "Kimi for Coding (custom)" ``` -`[models."".overrides]` 接受普通模型字段,例如 `max_context_size`、`max_output_size`、`capabilities`、`display_name`、`reasoning_key`、`adaptive_thinking`、`support_efforts` 和 `default_effort`。不接受身份 / 路由字段:`provider`、`model`、`protocol`、`beta_api` 和 `base_url`。 +`[models."".overrides]` 接受普通模型字段,例如 `max_context_size`、`max_input_size`、`max_output_size`、`capabilities`、`display_name`、`reasoning_key`、`adaptive_thinking`、`support_efforts`、`default_effort` 和 `off_effort`。不接受身份 / 路由字段:`provider`、`model`、`protocol`、`beta_api` 和 `base_url`。 无需修改配置文件也可以临时切换模型——通过 `KIMI_MODEL_*` 环境变量在内存里合成一个临时供应商,详见[用环境变量定义模型](./env-vars.md#用环境变量定义模型-kimi-model)。 diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md index b84351e9ef5..b062ff9938e 100644 --- a/docs/zh/configuration/providers.md +++ b/docs/zh/configuration/providers.md @@ -31,7 +31,7 @@ Kimi Code CLI 支持同时接入多家 LLM 平台——用 Kimi Code 托管服 添加时有两条路径: -- **Known third-party provider**:从 [models.dev](https://models.dev/) 拉取模型目录,选供应商 → 输入 API 密钥 → 选默认模型 +- **Known third-party provider**:从 [models.dev](https://models.dev/) 拉取模型目录,选供应商 → 输入 API 密钥 → 选默认模型。目录未声明协议类型的供应商(如 xai、openrouter 这类厂商专用 SDK)会按 OpenAI 兼容协议导入并显示 "guessed" 提示;目录没有可用端点时会先弹出 base URL 输入框;Amazon Bedrock / Cohere 等专有协议和无法识别的显式协议会被拒绝导入。已下线(deprecated)和 alpha 状态的模型不会出现在导入列表中 - **Custom registry (api.json)**:粘贴自定义 registry 地址和 Bearer token,CLI 自动创建 `providers` / `models` 条目。后续启动时,同一个 registry 地址下的供应商会一起刷新,因此上游新增、删除供应商以及模型元数据变化都会同步。 ::: warning diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 5d2abeb507c..6da18d72727 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -359,7 +359,7 @@ kimi provider catalog list anthropic #### `kimi provider catalog add ` -按 id 从 catalog 直接导入一个已知供应商,协议类型、base URL、模型信息均由 catalog 提供,只需提供 API key。catalog 未声明协议的供应商(如 xai、openrouter 这类专有 SDK)按 OpenAI 兼容协议导入,并在输出中标注 "guessed";catalog 未提供可用端点时需用 `--base-url` 显式指定。专有协议(如 Amazon Bedrock)无法导入。 +按 id 从 catalog 直接导入一个已知供应商,协议类型、base URL、模型信息均由 catalog 提供,只需提供 API key。catalog 未声明协议的供应商(如 xai、openrouter 这类厂商专用 SDK)按 OpenAI 兼容协议导入,并在输出中标注 "guessed";catalog 未提供可用端点时需用 `--base-url` 显式指定。专有协议(如 Amazon Bedrock)无法导入。 | 参数 / 选项 | 说明 | | --- | --- | diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 006bd39eafd..997e509e099 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -451,23 +451,17 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { private warnAboutAnthropicThinkingEffort(request: ResolvedLLMRequest): void { if (request.model.protocol !== 'anthropic') return; const effort = request.thinkingEffort; - if (effort === 'on') return; + if (effort === 'on' || effort === 'off') return; let code: string; let message: string; let knownEfforts: string | undefined; - if (effort === 'off') { - if (!request.model.alwaysThinking) return; - code = 'anthropic-thinking-cannot-disable'; - message = `Model "${request.model.name}" declares always-on thinking. The configured effort "off" will be sent unchanged to the Anthropic-compatible backend.`; - } else { - const supportEfforts = request.model.supportEfforts?.filter((value) => value.length > 0); - if (supportEfforts === undefined || supportEfforts.length === 0) return; - if (supportEfforts.includes(effort)) return; - code = 'anthropic-thinking-effort-not-listed'; - knownEfforts = supportEfforts.join(','); - message = `Thinking effort "${effort}" is not listed for model "${request.model.name}" (known: ${supportEfforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`; - } + const supportEfforts = request.model.supportEfforts?.filter((value) => value.length > 0); + if (supportEfforts === undefined || supportEfforts.length === 0) return; + if (supportEfforts.includes(effort)) return; + code = 'anthropic-thinking-effort-not-listed'; + knownEfforts = supportEfforts.join(','); + message = `Thinking effort "${effort}" is not listed for model "${request.model.name}" (known: ${supportEfforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`; const key = [code, request.modelAlias, request.model.name, effort, knownEfforts].join('\u0000'); if (this.emittedThinkingEffortWarnings.has(key)) return; diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index c285d8bcdb3..fe6c9345585 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -546,22 +546,16 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ const model = this.tryResolveRawModel(); if (model?.protocol !== 'anthropic') return; const effort = this.getEffectiveThinkingLevel(); - if (effort === 'on') return; + if (effort === 'on' || effort === 'off') return; let code: string; let message: string; let knownEfforts = ''; - if (effort === 'off') { - if (!model.alwaysThinking) return; - code = 'anthropic-thinking-cannot-disable'; - message = `Model "${model.name}" declares always-on thinking. The configured effort "off" will be sent unchanged to the Anthropic-compatible backend.`; - } else { - const efforts = model.supportEfforts?.filter((value) => value.length > 0); - if (efforts === undefined || efforts.length === 0 || efforts.includes(effort)) return; - knownEfforts = efforts.join(','); - code = 'anthropic-thinking-effort-not-listed'; - message = `Thinking effort "${effort}" is not listed for model "${model.name}" (known: ${efforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`; - } + const efforts = model.supportEfforts?.filter((value) => value.length > 0); + if (efforts === undefined || efforts.length === 0 || efforts.includes(effort)) return; + knownEfforts = efforts.join(','); + code = 'anthropic-thinking-effort-not-listed'; + message = `Thinking effort "${effort}" is not listed for model "${model.name}" (known: ${efforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`; const key = [code, model.id, model.name, effort, knownEfforts].join('\u0000'); if (this.emittedThinkingEffortWarnings.has(key)) return; diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index 3534eb3fd43..341d5b8c23d 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -18,6 +18,18 @@ * (protocol, providerType) pair contains a `withThinking` hook). Neither * hardcodes a vendor or protocol string — trait-driven thinking means * "thinking is driven by traits", which the registry answers. + * `requiresStrictThinkingValidation` reads the same identity for the + * strict-validation flag. Strict gates only listed-effort validation + * and the `'on'` projection; the always-on clamp is UNCONDITIONAL — a + * model that declares `always_thinking` never resolves to `'off'` on + * any wire (a claimed off state would be a lie, since upstream keeps + * reasoning at its default when no off encoding exists). Unlisted + * concrete efforts stay lenient on compatible transports + * (warn-and-send, `anthropic-thinking-effort-not-listed`) because the + * backend may accept values the local catalog does not list. The + * strict flag is declared by `kimiOpenAITrait` — Kimi's native API + * rejects unlisted efforts — and deliberately NOT by + * `kimiAnthropicTrait`. */ import { z } from 'zod'; @@ -95,23 +107,9 @@ export function usesTraitDrivenThinking( } /** - * ⚠ PHASE 6 PARITY PATCH — v1 `provider.type === 'kimi'` gate restored. - * * Whether client-side thinking-effort validation must be STRICT for the - * (protocol, providerType) pair: the resolved traits take thinking over and - * the last `withThinking` declarer marks `strictThinkingValidation`. - * - * This is the gate for client-side effort strictness (validation, the - * always-on clamp, and the `'on'` projection). The strict flag is declared - * by `kimiOpenAITrait` — Kimi's native API rejects unlisted efforts — and - * deliberately NOT by `kimiAnthropicTrait`: over the Anthropic - * transport the backend may accept efforts the local catalog metadata does - * not list, so the profile must stay lenient there (warn-and-send, with the - * `anthropic-thinking-*` warnings) instead of rejecting or rewriting the - * effort. Gating on plain `usesTraitDrivenThinking` (true for the - * anthropic pair registration too) made `setThinking` throw for Kimi-managed - * Anthropic models and left the warning path unreachable — a v1 behavioral - * regression. + * (protocol, providerType) pair — answered through the resolved adapter + * identity's `strictThinkingValidation` flag. */ export function requiresStrictThinkingValidation( registry: IProtocolAdapterRegistry, diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index 0667272fd70..a3586b77a3e 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -2217,6 +2217,41 @@ describe('FullCompaction', () => { await ctx.expectResumeMatches(); }); + it('triggers preemptive compaction against the declared input cap, not the total window', async () => { + let callCount = 0; + const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => { + callCount += 1; + if (callCount === 1) { + return textResult('Preemptive summary under the input cap.'); + } + await callbacks?.onMessagePart?.({ type: 'text', text: 'Answered after input-cap compaction.' }); + return textResult('Answered after input-cap compaction.'); + }; + const ctx = testAgent({ generate }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: { + ...CATALOGUED_MODEL_CAPABILITIES, + max_context_tokens: 200_000, + max_input_tokens: 150_000, + }, + tools: SNAPSHOT_VISIBLE_TOOLS, + }); + // 160k sits between the input-cap trigger (150k × 0.85 = 127.5k) and the + // total-window trigger (200k × 0.85 = 170k): compaction must fire only + // because the input cap is the prompt budget. + ctx.appendExchange(1, 'old user one', 'old assistant one', 160_000); + ctx.newEvents(); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'continue' }] }); + const events = await ctx.untilTurnEnd(); + + expect(callCount).toBe(2); + expect(events).toContainEqual( + expect.objectContaining({ event: 'compaction.started' }), + ); + }); + it('honors the observed provider window over a declared input cap', async () => { let callCount = 0; const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => { diff --git a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts index af899c17151..fe4eae06a4c 100644 --- a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts +++ b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts @@ -120,6 +120,84 @@ describe('Session legacy status (best-effort runtime state)', () => { }); }); + it('uses the input cap as the status denominator and clamps usage to 1', async () => { + const profile = { + _serviceBrand: undefined, + data: () => ({ + cwd: '/workspace', + modelAlias: 'gpt-5', + modelCapabilities: { + image_in: false, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 200_000, + max_input_tokens: 100_000, + dynamically_loaded_tools: false, + }, + thinkingLevel: 'medium', + systemPrompt: '', + }), + getModel: () => 'gpt-5', + getModelCapabilities: () => ({ + image_in: false, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 200_000, + max_input_tokens: 100_000, + dynamically_loaded_tools: false, + }), + getEffectiveThinkingLevel: () => 'medium', + } as unknown as IAgentProfileService; + const agent: IAgentScopeHandle = { + id: 'main', + kind: LifecycleScope.Agent, + accessor: accessor([ + [IAgentProfileService, profile], + [IAgentContextSizeService, { get: () => ({ size: 120_000, measured: 110_000, estimated: 10_000 }) }], + [IAgentPermissionModeService, { mode: 'manual' }], + [IAgentPlanService, { status: () => Promise.resolve(null) }], + [IAgentSwarmService, { isActive: false }], + [ + IAgentActivityView, + { state: () => ({ lifecycle: 'ready', background: [] }) }, + ], + ]), + dispose: () => {}, + }; + const agents = { + create: () => Promise.resolve(agent), + whenReady: () => Promise.resolve(agent), + list: () => [agent], + } as unknown as IAgentLifecycleService; + const session: ISessionScopeHandle = { + id: 'session-capped', + kind: LifecycleScope.Session, + accessor: accessor([ + [IAgentLifecycleService, agents], + [ISessionCronService, { _serviceBrand: undefined }], + ]), + dispose: () => {}, + }; + ix.stub(ISessionLifecycleService, { + resume: () => Promise.resolve(session), + get: () => session, + }); + ix.set(ISessionLegacyService, new SyncDescriptor(SessionLegacyService)); + + const status = await ix.get(ISessionLegacyService).status('session-capped'); + + // 120k in context against the 100k input cap (not the 200k window): + // usage would exceed the wire schema bound and is clamped to 1. + expect(status).toMatchObject({ + max_context_tokens: 100_000, + context_usage: 1, + }); + }); + it('fans a permission_mode patch out through the session agent registry', async () => { const broadcastPermissionMode = vi.fn(); const agent: IAgentScopeHandle = { diff --git a/packages/agent-core/src/agent/config/thinking.ts b/packages/agent-core/src/agent/config/thinking.ts index a25776ca939..a4166e9ad06 100644 --- a/packages/agent-core/src/agent/config/thinking.ts +++ b/packages/agent-core/src/agent/config/thinking.ts @@ -109,9 +109,12 @@ export function resolveThinkingEffort( // reads as absent. const configuredRaw = config?.effort?.trim().toLowerCase(); const configured = configuredRaw === undefined || configuredRaw === '' ? undefined : configuredRaw; + const requestedRaw = requested?.trim().toLowerCase(); + const requestedNormalized = + requestedRaw === undefined || requestedRaw === '' ? undefined : (requestedRaw as ThinkingEffort); let effort: ThinkingEffort; - if (requested !== undefined) { - effort = requested; + if (requestedNormalized !== undefined) { + effort = requestedNormalized; } else if (config?.enabled === false) { effort = 'off'; } else { diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 90444d709ad..26a2efe99c1 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -319,7 +319,7 @@ export class Agent { ): void { if (provider.name !== 'anthropic') return; const effort = provider.thinkingEffort; - if (effort === null || effort === 'on') return; + if (effort === null || effort === 'on' || effort === 'off') return; let warning: | { readonly code: string; readonly message: string; readonly knownEfforts?: string } @@ -331,22 +331,14 @@ export class Agent { : this.modelProvider?.resolveProviderConfig(modelAlias); if (resolved === undefined) return; - if (effort === 'off') { - if (resolved.alwaysThinking !== true) return; - warning = { - code: 'anthropic-thinking-cannot-disable', - message: `Model "${provider.modelName}" declares always-on thinking. The configured effort "off" will be sent unchanged to the Anthropic-compatible backend.`, - }; - } else { - const supportEfforts = resolved.supportEfforts?.filter((value) => value.length > 0); - if (supportEfforts === undefined || supportEfforts.length === 0) return; - if (supportEfforts.includes(effort)) return; - warning = { - code: 'anthropic-thinking-effort-not-listed', - message: `Thinking effort "${effort}" is not listed for model "${provider.modelName}" (known: ${supportEfforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`, - knownEfforts: supportEfforts.join(','), - }; - } + const supportEfforts = resolved.supportEfforts?.filter((value) => value.length > 0); + if (supportEfforts === undefined || supportEfforts.length === 0) return; + if (supportEfforts.includes(effort)) return; + warning = { + code: 'anthropic-thinking-effort-not-listed', + message: `Thinking effort "${effort}" is not listed for model "${provider.modelName}" (known: ${supportEfforts.join(', ')}). The configured value will be sent unchanged to the Anthropic-compatible backend.`, + knownEfforts: supportEfforts.join(','), + }; } catch { // Capability diagnostics must never turn an otherwise sendable request // into a client-side failure. diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 4bc63dccdcd..e8028c76c19 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -329,7 +329,7 @@ function toKosongProviderConfig( return { type: 'kimi', model, - baseUrl: providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'), + baseUrl: modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'), apiKey: providerApiKey(provider), generationKwargs: { prompt_cache_key: promptCacheKey }, ...defaultHeadersField({ @@ -342,7 +342,8 @@ function toKosongProviderConfig( return { type: 'google-genai', model, - baseUrl: providerValue(provider.baseUrl, provider.env, 'GOOGLE_GEMINI_BASE_URL'), + baseUrl: + modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'GOOGLE_GEMINI_BASE_URL'), apiKey: providerApiKey(provider), ...defaultHeadersField({ ...envCustomHeaders, @@ -373,7 +374,8 @@ function toKosongProviderConfig( // location detection, so the env fallback behaves exactly like // `base_url` — including deriving the region from an // `*-aiplatform.googleapis.com` host for the service-account path. - const baseUrl = providerValue(provider.baseUrl, provider.env, 'GOOGLE_VERTEX_BASE_URL'); + const baseUrl = + modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'GOOGLE_VERTEX_BASE_URL'); const useServiceAccount = hasVertexAIServiceEnv(provider, baseUrl); return { type: 'vertexai', diff --git a/packages/agent-core/test/agent/compaction/full.test.ts b/packages/agent-core/test/agent/compaction/full.test.ts index 3e14319e238..abc4138dfab 100644 --- a/packages/agent-core/test/agent/compaction/full.test.ts +++ b/packages/agent-core/test/agent/compaction/full.test.ts @@ -1846,6 +1846,40 @@ describe('FullCompaction', () => { await ctx.expectResumeMatches(); }); + it('triggers preemptive compaction against the declared input cap, not the total window', async () => { + let callCount = 0; + const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => { + callCount += 1; + if (callCount === 1) { + return textResult('Preemptive summary under the input cap.'); + } + await callbacks?.onMessagePart?.({ type: 'text', text: 'Answered after input-cap compaction.' }); + return textResult('Answered after input-cap compaction.'); + }; + const ctx = testAgent({ generate }); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: { + ...CATALOGUED_MODEL_CAPABILITIES, + max_context_tokens: 200_000, + max_input_tokens: 150_000, + }, + }); + // 160k sits between the input-cap trigger (150k × 0.85 = 127.5k) and the + // total-window trigger (200k × 0.85 = 170k): compaction must fire only + // because the input cap is the prompt budget. + ctx.appendExchange(1, 'old user one', 'old assistant one', 160_000); + ctx.newEvents(); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'continue' }] }); + const events = await ctx.untilTurnEnd(); + + expect(callCount).toBe(2); + expect(events).toContainEqual( + expect.objectContaining({ event: 'compaction.started' }), + ); + }); + it('honors the observed provider window over a declared input cap', async () => { let callCount = 0; const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => { diff --git a/packages/agent-core/test/agent/config/thinking.test.ts b/packages/agent-core/test/agent/config/thinking.test.ts index 349c28feba3..bb08bf86e65 100644 --- a/packages/agent-core/test/agent/config/thinking.test.ts +++ b/packages/agent-core/test/agent/config/thinking.test.ts @@ -154,6 +154,12 @@ describe('resolveThinkingEffort', () => { expect(resolveThinkingEffort(undefined, { effort: ' ' }, alwaysThinkingModel, false)).toBe('on'); }); + it('normalizes the requested effort (case/whitespace) on every wire', () => { + expect(resolveThinkingEffort(' OFF ', undefined, effortModel, false)).toBe('off'); + expect(resolveThinkingEffort(' Max ', undefined, effortModel, false)).toBe('max'); + expect(resolveThinkingEffort(' ', undefined, effortModel, false)).toBe('medium'); + }); + it('treats a configured off as absent when clamping always-thinking models', () => { expect(resolveThinkingEffort(undefined, { effort: 'off' }, alwaysThinkingEffortModel, false)).toBe( 'high', diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index 32848e9a3b7..1d209032348 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -383,6 +383,56 @@ describe('resolveRuntimeProvider maxOutputSize forwarding', () => { ); }); + it('prefers alias.baseUrl over the provider base URL on the kimi, google-genai, and openai_responses wires', () => { + const config = { + ...BASE_CONFIG, + providers: { + ...BASE_CONFIG.providers, + kimi: { type: 'kimi', apiKey: 'sk-kimi', baseUrl: 'https://kimi.example.test/v1' } as const, + google: { + type: 'google-genai', + apiKey: 'sk-google', + baseUrl: 'https://google.example.test', + } as const, + responses: { type: 'openai_responses', apiKey: 'sk-responses' } as const, + }, + models: { + ...BASE_CONFIG.models!, + 'kimi/tenant': { + provider: 'kimi', + model: 'kimi-k2', + maxContextSize: 1000, + baseUrl: 'https://tenant.example.test/v1', + }, + 'google/tenant': { + provider: 'google', + model: 'gemini-2.5-flash', + maxContextSize: 1000, + baseUrl: 'https://tenant.example.test/v1', + }, + 'responses/tenant': { + provider: 'responses', + model: 'gpt-5.5', + maxContextSize: 1000, + baseUrl: 'https://tenant.example.test/v1', + }, + }, + } as KimiConfig; + + expect(resolveRuntimeProvider({ config, model: 'kimi/tenant' }).provider).toMatchObject({ + type: 'kimi', + baseUrl: 'https://tenant.example.test/v1', + }); + expect(resolveRuntimeProvider({ config, model: 'google/tenant' }).provider).toMatchObject({ + type: 'google-genai', + baseUrl: 'https://tenant.example.test/v1', + }); + expect(resolveRuntimeProvider({ config, model: 'responses/tenant' }).provider).toMatchObject({ + type: 'openai_responses', + baseUrl: 'https://tenant.example.test/v1', + }); + }); + it('prefers alias.baseUrl over the provider base URL for the anthropic wire', () => { // Catalog gateway shape: provider default is the OpenAI wire, one model // carries an Anthropic protocol + endpoint override. @@ -1009,14 +1059,17 @@ describe('resolveThinkingEffort', () => { capabilities: ['thinking', 'always_thinking'], }; - it('returns the requested effort verbatim when one is provided', () => { + it('returns the requested effort (normalized) when one is provided', () => { expect(resolveThinkingEffort('on', { effort: 'medium' }, booleanModel)).toBe('on'); expect(resolveThinkingEffort('off', { effort: 'medium' }, booleanModel)).toBe('off'); expect(resolveThinkingEffort('low', { effort: 'medium' }, booleanModel)).toBe('low'); - // No normalization: empty / whitespace strings are returned as-is. - expect(resolveThinkingEffort('', { enabled: false, effort: 'medium' }, booleanModel)).toBe(''); + expect(resolveThinkingEffort(' Off ', { effort: 'medium' }, booleanModel)).toBe('off'); + // Empty / whitespace requests read as absent and fall through to config. + expect(resolveThinkingEffort('', { enabled: false, effort: 'medium' }, booleanModel)).toBe( + 'off', + ); expect(resolveThinkingEffort(' ', { enabled: false, effort: 'medium' }, booleanModel)).toBe( - ' ', + 'off', ); }); diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index fa9551e60bf..54720a82798 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -350,6 +350,40 @@ describe('SessionEventBroadcaster', () => { ]); }); + it('publishes the input cap as the status context limit when declared', async () => { + const lc = new FakeLifecycle(); + const main = lc.addAgent('main'); + const usage = { + byModel: { + 'example-model': { inputOther: 1, output: 2, inputCacheRead: 0, inputCacheCreation: 0 }, + }, + total: { inputOther: 1, output: 2, inputCacheRead: 0, inputCacheCreation: 0 }, + }; + main.set(IAgentContextSizeService, { get: () => ({ size: 10 }) }); + main.set(IAgentProfileService, { + getModel: () => 'example-model', + getModelCapabilities: () => ({ max_context_tokens: 128_000, max_input_tokens: 64_000 }), + }); + main.set(IAgentUsageService, { status: () => usage }); + main.set(IWireService, { + getModel: (model: unknown) => { + expect(model).toBe(ContextSizeModel); + return { length: 0, tokens: 8 }; + }, + }); + sessions.set('s1', lc); + const { target, envelopes } = collectingTarget(); + await bc.subscribe('s1', target); + + main.bus.emit(agentEvent('agent.status.updated', { usage })); + await bc.getCursor('s1'); + + const statuses = envelopes.filter((envelope) => envelope.type === 'agent.status.updated'); + expect(statuses.map((envelope) => envelope.payload)).toMatchObject([ + { type: 'agent.status.updated', maxContextTokens: 64_000 }, + ]); + }); + it('projects agent activity state into legacy running and ended phases', async () => { const lc = new FakeLifecycle(); const main = lc.addAgent('main'); diff --git a/packages/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts index bce6e9da7ba..6c7c611c817 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -411,7 +411,28 @@ export function catalogProviderModels(entry: CatalogProviderEntry): CatalogModel const providerWire = resolveCatalogWire(entry); return Object.values(entry.models ?? {}) .map((raw) => applyModelProviderOverride(catalogModelToCapability(raw), raw, entry, providerWire)) - .filter((model): model is CatalogModel => model !== undefined); + .filter((model): model is CatalogModel => model !== undefined) + .map((model) => { + // The always-thinking inference ("effort levels, no toggle, no 'none' + // — reasoning cannot be turned off") must not fire where the wire has + // a true protocol-level disable the effort list can never show: + // Anthropic and Kimi both encode off as `thinking: {type: 'disabled'}`, + // so marking those models always-on would hide a working off. On every + // other wire the same catalog shape is exactly the evidence the marker + // exists for — gpt-5-class models reject `reasoning_effort: 'none'`, + // and Gemini 3's floor is `thinkingLevel: 'MINIMAL'` (still reasoning, + // merely with thoughts hidden) — so there the marker keeps the UI from + // offering an off that does not exist. + const protocol = model.protocol ?? providerWire; + if ( + model.alwaysThinking === true && + (protocol === 'anthropic' || protocol === 'kimi') + ) { + const { alwaysThinking: _dropped, ...rest } = model; + return rest as CatalogModel; + } + return model; + }); } /** diff --git a/packages/kosong/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index 319886cf825..ace62983628 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -425,6 +425,97 @@ describe('catalogModelToCapability', () => { expect(toggleable?.offEffort).toBeUndefined(); }); + it('strips the always-thinking inference only where the wire encodes a true off', () => { + // Claude on the Anthropic wire: off is natively encodable + // (`thinking: {type: 'disabled'}`), so the no-toggle/no-none inference + // must NOT mark it always-on even though models.dev declares levels only. + const anthropic = catalogProviderModels({ + id: 'anthropic', + npm: '@ai-sdk/anthropic', + api: 'https://api.anthropic.com', + models: { + 'claude-opus-4-6': { + id: 'claude-opus-4-6', + reasoning: true, + reasoning_options: [{ type: 'effort', values: ['low', 'medium', 'high', 'max'] }], + limit: { context: 200000 }, + }, + }, + }); + expect(anthropic[0]?.alwaysThinking).toBeUndefined(); + expect(anthropic[0]?.supportEfforts).toEqual(['low', 'medium', 'high', 'max']); + + // The Kimi wire shares the same protocol-level disable + // (`thinking: {type: 'disabled'}`), so the marker is stripped there too. + const kimi = catalogProviderModels({ + id: 'kimi-entry', + type: 'kimi', + models: { + 'kimi-model': { + id: 'kimi-model', + reasoning: true, + reasoning_options: [{ type: 'effort', values: ['low', 'high', 'max'] }], + limit: { context: 262144 }, + }, + }, + }); + expect(kimi[0]?.alwaysThinking).toBeUndefined(); + + // The same shape on the OpenAI wire keeps the marker (gpt-5-class + // models really cannot be turned off). + const openai = catalogProviderModels({ + id: 'openai', + npm: '@ai-sdk/openai', + models: { + 'gpt-5': { + id: 'gpt-5', + reasoning: true, + reasoning_options: [{ type: 'effort', values: ['minimal', 'low', 'medium', 'high'] }], + limit: { context: 400000, input: 272000 }, + }, + }, + }); + expect(openai[0]?.alwaysThinking).toBe(true); + + // Gemini 3 on the Google wires also keeps the marker: its floor is + // `thinkingLevel: 'MINIMAL'` with suppressed thoughts — still reasoning, + // so an "off" option would be a lie. + for (const type of ['google-genai', 'vertexai'] as const) { + const google = catalogProviderModels({ + id: `google-${type}`, + type, + models: { + 'gemini-3-pro-preview': { + id: 'gemini-3-pro-preview', + reasoning: true, + reasoning_options: [{ type: 'effort', values: ['low', 'high'] }], + limit: { context: 1048576 }, + }, + }, + }); + expect(google[0]?.alwaysThinking).toBe(true); + } + + // A Claude model materialized onto the Anthropic wire through a gateway + // override loses the marker with the protocol change. + const gateway = catalogProviderModels({ + id: 'gateway', + npm: '@ai-sdk/openai-compatible', + api: 'https://gateway.example.test/v1', + models: { + 'vendor/claude-model': { + id: 'vendor/claude-model', + reasoning: true, + reasoning_options: [{ type: 'effort', values: ['low', 'medium', 'high'] }], + limit: { context: 200000 }, + provider: { npm: '@ai-sdk/anthropic', api: 'https://gateway.example.test/anthropic/v1' }, + }, + }, + }); + expect(gateway[0]?.protocol).toBe('anthropic'); + expect(gateway[0]?.alwaysThinking).toBeUndefined(); + }); + it('yields no effort list for toggle-only, budget_tokens, or empty reasoning_options', () => { for (const reasoning_options of [ [{ type: 'toggle' }],