This repository was archived by the owner on Sep 23, 2026. It is now read-only.
Repository navigation
fix(chat-provider): preserve refreshed OAuth token on connection recovery #2004
Merged
+124
−5
Merged
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.mdis auto-generated from the rootCHANGELOG.mdviadocs/scripts/sync-changelog.mjs(seedocs/AGENTS.md). To avoid the doc page drifting, please regenerate it withnpm run sync/make gen-docsrather than editing this file directly (and ensure the committed output matches the root changelog).There was a problem hiding this comment.
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