mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
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:
@@ -66,6 +66,21 @@ from src.core.api_format.conversion.stream_state import StreamState
|
||||
from src.core.logger import logger
|
||||
|
||||
|
||||
def _normalize_chat_completion_id(raw_id: str) -> str:
|
||||
"""将非 chatcmpl- 前缀的 ID 规范化为 Chat Completions 格式。
|
||||
|
||||
OpenAI Chat Completions 响应的 id 约定使用 chatcmpl- 前缀,
|
||||
当上游返回 Responses API 格式(resp_ 前缀)或其他格式时需要转换。
|
||||
"""
|
||||
if not raw_id:
|
||||
return "chatcmpl-unknown"
|
||||
if raw_id.startswith("chatcmpl-"):
|
||||
return raw_id
|
||||
if raw_id.startswith("resp_"):
|
||||
return "chatcmpl-" + raw_id[5:]
|
||||
return raw_id
|
||||
|
||||
|
||||
class OpenAINormalizer(FormatNormalizer):
|
||||
# 新模式:ApiFamily + EndpointKind 的 signature key
|
||||
FORMAT_ID = "openai:chat"
|
||||
@@ -450,7 +465,7 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
# 优先使用用户请求的原始模型名,回退到上游返回的模型名
|
||||
model_name = requested_model if requested_model else internal.model
|
||||
out: dict[str, Any] = {
|
||||
"id": internal.id or "chatcmpl-unknown",
|
||||
"id": _normalize_chat_completion_id(internal.id or ""),
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": model_name,
|
||||
@@ -728,7 +743,7 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
|
||||
def base_chunk(delta: dict[str, Any], finish_reason: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": state.message_id or "chatcmpl-stream",
|
||||
"id": _normalize_chat_completion_id(state.message_id or "chatcmpl-stream"),
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": state.model or "",
|
||||
@@ -772,7 +787,7 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
if isinstance(event, ContentBlockStartEvent) and event.block_type == ContentType.TOOL_USE:
|
||||
tool_id = event.tool_id or ""
|
||||
tool_name = event.tool_name or ""
|
||||
tool_index = self._ensure_tool_call_index(ss, tool_id)
|
||||
tool_index = self._ensure_tool_call_index(ss, tool_id, event.block_index)
|
||||
out.append(
|
||||
base_chunk(
|
||||
{
|
||||
@@ -839,7 +854,7 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
return out
|
||||
|
||||
if isinstance(event, ToolCallDeltaEvent):
|
||||
tool_index = self._ensure_tool_call_index(ss, event.tool_id)
|
||||
tool_index = self._ensure_tool_call_index(ss, event.tool_id, event.block_index)
|
||||
# 后续 delta 只需 index + function.arguments;id/type 仅在 ContentBlockStartEvent 首次发送
|
||||
tc_delta: dict[str, Any] = {
|
||||
"index": tool_index,
|
||||
@@ -1783,17 +1798,34 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
ss["next_block_index"] = next_idx + 1
|
||||
return next_idx
|
||||
|
||||
def _ensure_tool_call_index(self, ss: dict[str, Any], tool_id: str) -> int:
|
||||
mapping = ss.get("tool_id_to_index")
|
||||
def _ensure_tool_call_index(
|
||||
self, ss: dict[str, Any], tool_id: str, block_index: int | None = None
|
||||
) -> int:
|
||||
mapping: dict[str, int] = ss.get("tool_id_to_index") # type: ignore[assignment]
|
||||
if not isinstance(mapping, dict):
|
||||
mapping = {}
|
||||
ss["tool_id_to_index"] = mapping
|
||||
ss["next_tool_index"] = 0
|
||||
|
||||
if tool_id in mapping:
|
||||
return int(mapping[tool_id])
|
||||
# block_index -> tool_index 的辅助映射,用于 tool_id 丢失时回落
|
||||
block_mapping: dict[int, int] = ss.get("block_to_tool_index") # type: ignore[assignment]
|
||||
if not isinstance(block_mapping, dict):
|
||||
block_mapping = {}
|
||||
ss["block_to_tool_index"] = block_mapping
|
||||
|
||||
# 优先用 tool_id 查找
|
||||
if tool_id and tool_id in mapping:
|
||||
return mapping[tool_id]
|
||||
|
||||
# tool_id 为空时,用 block_index 回落查找
|
||||
if not tool_id and block_index is not None and block_index in block_mapping:
|
||||
return block_mapping[block_index]
|
||||
|
||||
# 首次出现:分配新 index
|
||||
next_idx = int(ss.get("next_tool_index") or 0)
|
||||
if tool_id:
|
||||
mapping[tool_id] = next_idx
|
||||
if block_index is not None:
|
||||
block_mapping[block_index] = next_idx
|
||||
ss["next_tool_index"] = next_idx + 1
|
||||
return next_idx
|
||||
|
||||
@@ -31,12 +31,49 @@ from src.models.database import ProviderAPIKey
|
||||
# 其余 reason 属于 token 级别异常,成功刷新 token 后自动清除。
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX = "[ACCOUNT_BLOCK] "
|
||||
|
||||
# 上游返回 "token 已失效" 语义的关键词(小写匹配)。
|
||||
# 被 codex_refresher 前向分类和 oauth_token 回溯清理共用。
|
||||
TOKEN_INVALIDATED_KEYWORDS: tuple[str, ...] = (
|
||||
"authentication token has been invalidated",
|
||||
"token has been invalidated",
|
||||
)
|
||||
|
||||
# 回溯清理专用:历史写入的中文 reason 也需匹配
|
||||
_LEGACY_TOKEN_INVALID_KEYWORDS: tuple[str, ...] = (
|
||||
*TOKEN_INVALIDATED_KEYWORDS,
|
||||
"codex token 无效或已过期",
|
||||
)
|
||||
|
||||
|
||||
def looks_like_token_invalidated(message: str | None) -> bool:
|
||||
"""判断上游错误消息是否表示 access token 已失效/被轮换。"""
|
||||
lowered = str(message or "").strip().lower()
|
||||
return any(keyword in lowered for keyword in TOKEN_INVALIDATED_KEYWORDS)
|
||||
|
||||
|
||||
def _is_refresh_recoverable_account_block(reason: str | None) -> bool:
|
||||
"""历史兼容:部分 token 级异常曾被错误写成 [ACCOUNT_BLOCK]。
|
||||
|
||||
这类原因在手动刷新成功后应自动清除,否则前端会继续展示
|
||||
"Token 失效/账号异常",并阻止 Key 恢复调度。
|
||||
"""
|
||||
if not reason:
|
||||
return False
|
||||
text = str(reason)
|
||||
if not text.startswith(OAUTH_ACCOUNT_BLOCK_PREFIX):
|
||||
return False
|
||||
lowered = text[len(OAUTH_ACCOUNT_BLOCK_PREFIX) :].strip().lower()
|
||||
return any(keyword in lowered for keyword in _LEGACY_TOKEN_INVALID_KEYWORDS)
|
||||
|
||||
|
||||
def is_account_level_block(reason: str | None) -> bool:
|
||||
"""判断 oauth_invalid_reason 是否属于账号级别的 block(刷新 token 无法修复)。"""
|
||||
if not reason:
|
||||
return False
|
||||
return str(reason).startswith(OAUTH_ACCOUNT_BLOCK_PREFIX)
|
||||
text = str(reason)
|
||||
return text.startswith(
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX
|
||||
) and not _is_refresh_recoverable_account_block(text)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -116,4 +153,9 @@ async def resolve_oauth_access_token(
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["OAuthAccessTokenResult", "resolve_oauth_access_token"]
|
||||
__all__ = [
|
||||
"OAuthAccessTokenResult",
|
||||
"TOKEN_INVALIDATED_KEYWORDS",
|
||||
"looks_like_token_invalidated",
|
||||
"resolve_oauth_access_token",
|
||||
]
|
||||
|
||||
@@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
|
||||
from src.core.crypto import crypto_service
|
||||
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||
from src.services.provider.auth import get_provider_auth
|
||||
from src.services.provider.oauth_token import looks_like_token_invalidated
|
||||
from src.services.provider.pool.account_state import (
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX,
|
||||
OAUTH_EXPIRED_PREFIX,
|
||||
@@ -67,14 +68,6 @@ def _extract_error_message_from_response(response: httpx.Response) -> str:
|
||||
return text[:300] if text else ""
|
||||
|
||||
|
||||
def _looks_like_token_invalidated(message: str | None) -> bool:
|
||||
lowered = str(message or "").strip().lower()
|
||||
return (
|
||||
"authentication token has been invalidated" in lowered
|
||||
or "token has been invalidated" in lowered
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_account_deactivated(message: str | None) -> bool:
|
||||
lowered = str(message or "").strip().lower()
|
||||
return "account has been deactivated" in lowered or "account deactivated" in lowered
|
||||
@@ -97,6 +90,12 @@ def _build_structured_invalid_reason(*, status_code: int, upstream_message: str
|
||||
detail = message or "OpenAI 账号已停用"
|
||||
return f"{OAUTH_ACCOUNT_BLOCK_PREFIX}{detail}"
|
||||
|
||||
# Codex 某些场景会返回 403,但语义仍是 access token 已失效/被轮换。
|
||||
# 这类异常可通过 refresh_token 恢复,不应落成账号级 block。
|
||||
if looks_like_token_invalidated(message):
|
||||
detail = message or "Codex Token 无效或已过期"
|
||||
return f"{OAUTH_EXPIRED_PREFIX}{detail}"
|
||||
|
||||
if status_code == 401:
|
||||
detail = message or "Codex Token 无效或已过期 (401)"
|
||||
return f"{OAUTH_EXPIRED_PREFIX}{detail}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user