fix(openai,oauth): 修复 resp_ ID 前缀转换和 token 失效误判为账号级 block

- OpenAI normalizer: resp_ 前缀 ID 规范化为 chatcmpl- 前缀,流式 tool_call index 增加 block_index 回落
- OAuth token: 提取 token 失效关键词为共享常量,token invalidated 不再被判定为账号级 block
- Codex refresher: 403 + token invalidated 标记为 OAUTH_EXPIRED,允许 refresh_token 恢复
This commit is contained in:
fawney19
2026-03-15 16:56:58 +08:00
parent f92b0943b5
commit 8cc70934da
6 changed files with 348 additions and 19 deletions

View File

@@ -702,3 +702,170 @@ def test_real_claude_cli_stream_to_openai_cli() -> None:
e for e in all_events if e.get("type") in ("response.completed", "response.done")
]
assert len(done_events) >= 1
def test_resp_id_normalized_to_chatcmpl_in_response_conversion() -> None:
"""openai:cli -> openai:chat 响应转换时 resp_ 前缀应转为 chatcmpl-。"""
reg = _make_registry_with_cli()
cli_response = {
"id": "resp_67ccfcdd16748190a91872c75d38539e",
"object": "response",
"model": "gpt-4o",
"status": "completed",
"output": [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "hello"}],
}
],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
}
chat_response = reg.convert_response(
cli_response, "openai:cli", "openai:chat", requested_model="gpt-4o"
)
assert chat_response["id"].startswith("chatcmpl-")
assert "resp_" not in chat_response["id"]
def test_resp_id_normalized_to_chatcmpl_in_stream_conversion() -> None:
"""openai:cli -> openai:chat 流式转换时 chunk id 应为 chatcmpl- 前缀。"""
reg = _make_registry_with_cli()
cli_chunks: list[dict[str, Any]] = [
{
"type": "response.created",
"response": {
"id": "resp_abc123",
"object": "response",
"model": "gpt-4o",
"status": "in_progress",
"output": [],
},
},
{
"type": "response.output_text.delta",
"delta": "hi",
"content_index": 0,
"output_index": 0,
},
{
"type": "response.completed",
"response": {
"id": "resp_abc123",
"object": "response",
"model": "gpt-4o",
"status": "completed",
"output": [],
"usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7},
},
},
]
state = StreamState()
all_events: list[dict[str, Any]] = []
for chunk in cli_chunks:
events = reg.convert_stream_chunk(chunk, "openai:cli", "openai:chat", state=state)
all_events.extend(events)
# 所有 chunk 的 id 都应为 chatcmpl- 前缀
for event in all_events:
event_id = event.get("id", "")
if event_id:
assert event_id.startswith(
"chatcmpl-"
), f"chunk id should start with chatcmpl-: {event_id}"
def test_tool_call_stream_index_stable_across_deltas() -> None:
"""openai:cli -> openai:chat 流式 tool_call 的所有 delta 应保持同一 index。"""
reg = _make_registry_with_cli()
cli_chunks: list[dict[str, Any]] = [
{
"type": "response.created",
"response": {
"id": "resp_tc_idx",
"object": "response",
"model": "gpt-4o",
"status": "in_progress",
"output": [],
},
},
{
"type": "response.output_item.added",
"output_index": 0,
"item": {
"type": "function_call",
"call_id": "fc_001",
"id": "fc_001",
"name": "get_weather",
"status": "in_progress",
"arguments": "",
},
},
{
"type": "response.function_call_arguments.delta",
"output_index": 0,
"item_id": "fc_001",
"delta": '{"loc',
},
{
"type": "response.function_call_arguments.delta",
"output_index": 0,
"item_id": "fc_001",
"delta": 'ation": "Tokyo"}',
},
{
"type": "response.function_call_arguments.done",
"output_index": 0,
"item_id": "fc_001",
"arguments": '{"location": "Tokyo"}',
},
{
"type": "response.output_item.done",
"output_index": 0,
"item": {
"type": "function_call",
"call_id": "fc_001",
"id": "fc_001",
"name": "get_weather",
"status": "completed",
"arguments": '{"location": "Tokyo"}',
},
},
{
"type": "response.completed",
"response": {
"id": "resp_tc_idx",
"object": "response",
"model": "gpt-4o",
"status": "completed",
"output": [],
"usage": {"input_tokens": 20, "output_tokens": 10, "total_tokens": 30},
},
},
]
state = StreamState()
all_events: list[dict[str, Any]] = []
for chunk in cli_chunks:
events = reg.convert_stream_chunk(chunk, "openai:cli", "openai:chat", state=state)
all_events.extend(events)
# 收集所有 tool_calls chunk
tc_indices: list[int] = []
for event in all_events:
for choice in event.get("choices", []):
tcs = choice.get("delta", {}).get("tool_calls")
if isinstance(tcs, list):
for tc in tcs:
tc_indices.append(tc["index"])
assert len(tc_indices) >= 2, f"expected at least 2 tool_call chunks, got {len(tc_indices)}"
# 同一个 tool call 的所有 chunk 必须使用相同的 index
assert all(
idx == tc_indices[0] for idx in tc_indices
), f"tool_call index should be stable, got: {tc_indices}"

View File

@@ -107,3 +107,40 @@ def test_mark_refresh_token_invalid_persists_detached_key(
assert row.oauth_invalid_at is not None
assert str(key.oauth_invalid_reason).startswith("[REFRESH_FAILED] Token 续期失败 (401)")
assert "refresh_token_reused" in str(row.oauth_invalid_reason)
def test_account_block_token_invalidated_is_refresh_recoverable() -> None:
from src.services.provider.oauth_token import is_account_level_block
assert (
is_account_level_block(
"[ACCOUNT_BLOCK] Authentication token has been invalidated. Please sign in again."
)
is False
)
def test_persist_refreshed_token_clears_legacy_token_invalidated_account_block(
monkeypatch: pytest.MonkeyPatch,
) -> None:
key = SimpleNamespace(
id="key-1",
api_key="old-api",
auth_config="old-config",
oauth_invalid_at=datetime.now(timezone.utc),
oauth_invalid_reason=(
"[ACCOUNT_BLOCK] Authentication token has been invalidated. Please sign in again."
),
)
monkeypatch.setattr(
module, "object_session", lambda _key: (_ for _ in ()).throw(RuntimeError())
)
monkeypatch.setattr(module.crypto_service, "encrypt", lambda value: f"enc:{value}")
module._persist_refreshed_token(key, "new-token", {"refresh_token": "rt-2"})
assert key.api_key == "enc:new-token"
assert key.auth_config == 'enc:{"refresh_token": "rt-2"}'
assert key.oauth_invalid_at is None
assert key.oauth_invalid_reason is None

View File

@@ -264,6 +264,58 @@ async def test_codex_refresher_http_402_sets_quota_exhausted_metadata(
assert state_updates["k1"]["oauth_invalid_reason"] is None
@pytest.mark.asyncio
async def test_codex_refresher_http_403_token_invalidated_marks_oauth_expired(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.services.provider_keys.quota_refresh import codex_refresher as module
key = SimpleNamespace(
id="k1", name="K1", api_key="enc", auth_type="api_key", auth_config=None, proxy=None
)
provider = SimpleNamespace(proxy=None)
endpoint = SimpleNamespace()
metadata_updates: dict[str, dict[str, Any]] = {}
state_updates: dict[str, dict[str, Any]] = {}
async def _fake_auth_info(_endpoint: Any, _key: Any) -> Any:
return None
_install_module(
monkeypatch,
"src.services.proxy_node.resolver",
{
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
},
)
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
monkeypatch.setattr(module.crypto_service, "decrypt", lambda _v: "sk-test")
response = _FakeResponse(
status_code=403,
payload={"error": {"message": "Authentication token has been invalidated."}},
)
monkeypatch.setattr(
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
)
result = await refresh_codex_key_quota(
db=cast(Any, _FakeDB()),
provider=cast(Any, provider),
key=cast(Any, key),
endpoint=cast(Any, endpoint),
codex_wham_usage_url="https://example.test",
metadata_updates=metadata_updates,
state_updates=state_updates,
)
assert result["status"] == "forbidden"
assert result["status_code"] == 403
assert result["auto_disabled"] is True
assert state_updates["k1"]["is_active"] is False
assert str(state_updates["k1"]["oauth_invalid_reason"]).startswith("[OAUTH_EXPIRED]")
@pytest.mark.asyncio
async def test_codex_refresher_success_updates_metadata(
monkeypatch: pytest.MonkeyPatch,