Skip to content
This repository was archived by the owner on Sep 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Only write entries that are worth mentioning to users.

## Unreleased

- Kosong: Fix stale API key after OAuth token refresh in Kimi provider — `on_retryable_error` now reads the current `api_key` from the live client instead of the cached `_api_key`, so that OAuth token refreshes applied via `client.api_key` are preserved when the client is rebuilt after a retryable error
- Shell: Fix `Rejected by user` misleading message when an approval modal times out — after the 300s safety timeout, the tool call now rejects with `Rejected: approval timed out`, so users returning to their session after stepping away can tell the rejection was a timeout rather than a manual rejection. Pass `--yolo`/`-y` to auto-approve tool calls if you regularly leave sessions unattended
- Kosong: Fix parallel tool results being split into multiple user messages in Anthropic provider — consecutive tool-result-only user messages are now merged into a single message, complying with the Anthropic Messages API spec that all `tool_use` blocks in an assistant turn must be answered within one user message; this fixes 400 errors on strict Anthropic-compatible backends (e.g. DeepSeek `/anthropic` endpoint) and prevents the official backend from silently teaching the model to avoid parallel tool calls

Expand Down
1 change: 1 addition & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This page documents the changes in each Kimi Code CLI release.

## Unreleased

- Kosong: Fix stale API key after OAuth token refresh in Kimi provider — `on_retryable_error` now reads the current `api_key` from the live client instead of the cached `_api_key`, so that OAuth token refreshes applied via `client.api_key` are preserved when the client is rebuilt after a retryable error

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs/en/release-notes/changelog.md is auto-generated from the root CHANGELOG.md via docs/scripts/sync-changelog.mjs (see docs/AGENTS.md). To avoid the doc page drifting, please regenerate it with npm run sync / make gen-docs rather than editing this file directly (and ensure the committed output matches the root changelog).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

- Shell: Fix `Rejected by user` misleading message when an approval modal times out — after the 300s safety timeout, the tool call now rejects with `Rejected: approval timed out`, so users returning to their session after stepping away can tell the rejection was a timeout rather than a manual rejection. Pass `--yolo`/`-y` to auto-approve tool calls if you regularly leave sessions unattended
- Kosong: Fix parallel tool results being split into multiple user messages in Anthropic provider — consecutive tool-result-only user messages are now merged into a single message, complying with the Anthropic Messages API spec that all `tool_use` blocks in an assistant turn must be answered within one user message; this fixes 400 errors on strict Anthropic-compatible backends (e.g. DeepSeek `/anthropic` endpoint) and prevents the official backend from silently teaching the model to avoid parallel tool calls

Expand Down
1 change: 1 addition & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

## 未发布

- Kosong:修复 Kimi 供应商在 OAuth 令牌刷新后仍使用过期的 API 密钥的问题——`on_retryable_error` 现在从当前 client 读取 `api_key`,而不是缓存的 `_api_key`,因此在可重试错误后重建 client 时会保留通过 `client.api_key` 应用的 OAuth 令牌刷新
- Shell:修复 approval 弹窗超时后被误报为 `Rejected by user` 的问题——300 秒安全超时后,工具调用会以 `Rejected: approval timed out` 拒绝,让离开电脑一段时间后回来的用户能分辨出这是超时而非自己的手动拒绝。经常长时间离开的话可以加 `--yolo`/`-y` 自动批准工具调用
- Kosong:修复 Anthropic 供应商将并行工具结果拆分到多个 user message 的问题——现在会将仅包含工具结果的连续 user message 合并为单条消息,以符合 Anthropic Messages API 规范(assistant 一轮中的所有 `tool_use` 必须在同一条 user message 内回答);修复了严格兼容后端(如 DeepSeek `/anthropic` 接口)返回 400 错误的问题,并避免官方后端静默地引导模型放弃并行工具调用

Expand Down
1 change: 1 addition & 0 deletions packages/kosong/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Kimi: Fix stale API key after OAuth token refresh — `on_retryable_error` now reads the current `api_key` from the live client instead of the cached `_api_key`, so that OAuth token refreshes applied via `client.api_key` are preserved when the client is rebuilt after a retryable error
- Anthropic: Fix parallel tool results being split into multiple user messages — consecutive tool-result-only user messages are now merged into a single message, complying with the Anthropic Messages API spec that all `tool_use` blocks in an assistant turn must be answered within one user message; this fixes 400 errors on strict Anthropic-compatible backends (e.g. DeepSeek `/anthropic` endpoint) and prevents the official backend from silently teaching the model to avoid parallel tool calls

## 0.50.0 (2026-04-17)
Expand Down
6 changes: 5 additions & 1 deletion packages/kosong/src/kosong/chat_provider/kimi.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,15 @@ async def generate(

def on_retryable_error(self, error: BaseException) -> bool:
old_client = self.client
# Read api_key from the live client (not self._api_key) so that
# OAuth token refreshes applied via client.api_key are preserved.
current_api_key = old_client.api_key
self.client = create_openai_client(
api_key=self._api_key,
api_key=current_api_key,
base_url=self._base_url,
client_kwargs=self._client_kwargs,
)
self._api_key = current_api_key
close_replaced_openai_client(old_client, client_kwargs=self._client_kwargs)
return True

Expand Down
114 changes: 114 additions & 0 deletions packages/kosong/tests/test_kimi_stale_api_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Verify that on_retryable_error preserves the latest api_key after token refresh.

Bug scenario (now fixed):
1. Kimi() constructed with api_key="token-A" → self._api_key = "token-A"
2. OAuth refresh updates client.api_key = "token-B" (via _apply_access_token)
3. Network error triggers on_retryable_error() → rebuilds client
4. BEFORE FIX: new client used stale "token-A" → 401
5. AFTER FIX: new client uses current "token-B" → works correctly
"""

import httpx
import pytest
import respx

from kosong.chat_provider.kimi import Kimi


def test_on_retryable_error_preserves_refreshed_api_key():
"""on_retryable_error must use the current client.api_key, not stale _api_key."""
provider = Kimi(model="test-model", api_key="token-A")

# Simulate OAuth token refresh: _apply_access_token updates client.api_key
provider.client.api_key = "token-B"

assert provider.client.api_key == "token-B"

# Simulate a network error that triggers on_retryable_error
class FakeConnectionError(Exception):
pass

provider.on_retryable_error(FakeConnectionError("connection lost"))

# FIXED: the rebuilt client must use the latest token-B
assert provider.client.api_key == "token-B", (
"on_retryable_error should preserve the refreshed api_key 'token-B'"
)


def test_api_key_correct_after_construction():
"""Sanity check: client.api_key matches the value passed at construction."""
provider = Kimi(model="test-model", api_key="initial-token")
assert provider.client.api_key == "initial-token"


def test_on_retryable_error_idempotent():
"""Multiple on_retryable_error calls should all preserve the current key."""
provider = Kimi(model="test-model", api_key="token-A")

provider.client.api_key = "token-B"
provider.on_retryable_error(Exception("error 1"))
assert provider.client.api_key == "token-B"

provider.client.api_key = "token-C"
provider.on_retryable_error(Exception("error 2"))
assert provider.client.api_key == "token-C"


@pytest.mark.asyncio
async def test_rebuilt_client_sends_correct_authorization_header():
"""End-to-end: after token refresh + on_retryable_error, the rebuilt client
must send the refreshed token in the actual HTTP Authorization header.

This verifies the complete bug chain:
1. Provider constructed with stale token
2. Token refreshed externally (simulating _apply_access_token)
3. on_retryable_error rebuilds the client
4. The new client's actual HTTP request uses the refreshed token
"""
captured_headers: dict[str, str] = {}

def capture_headers(request: httpx.Request) -> httpx.Response:
captured_headers["Authorization"] = request.headers.get("authorization", "")
return httpx.Response(
200,
json={
"id": "test",
"object": "chat.completion",
"model": "test-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "ok"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
)

with respx.mock:
respx.post("https://api.moonshot.ai/v1/chat/completions").mock(side_effect=capture_headers)

provider = Kimi(model="test-model", api_key="token-A", stream=False)

# Step 1: initial request uses token-A
result = await provider.generate("", [], [])
async for _ in result:
pass
assert captured_headers["Authorization"] == "Bearer token-A"

# Step 2: simulate OAuth token refresh
provider.client.api_key = "token-B"

# Step 3: simulate network error → on_retryable_error rebuilds client
provider.on_retryable_error(Exception("connection lost"))

# Step 4: the rebuilt client must send token-B, not token-A
result = await provider.generate("", [], [])
async for _ in result:
pass
assert captured_headers["Authorization"] == "Bearer token-B", (
"After token refresh + on_retryable_error, the client must send "
"the refreshed token-B in the Authorization header, not the stale token-A"
)
Loading