fix(kiro): 加固 Kiro adapter 错误处理与请求构建逻辑

- 提取 request.py 统一 URL/headers/payload 构建,消除 handler_adapter_base 与 envelope 的重复逻辑
- 新增 error_enhancer 模块,分类 HTTP 状态码与连接错误,增强上游错误诊断信息
- envelope 实现 extract_error_text / on_http_status / on_connection_error,透传错误上下文到 eventstream rewriter
- KiroRequestContext 扩展错误状态字段,支持网络诊断信息传递
- provider_oauth_utils 改用 importlib 动态加载,避免 core 层对 services 的静态依赖
- idc auth_method 下跳过 profileArn,修复 usage 查询参数

Closes #247

Co-authored-by: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
fawney19
2026-03-19 13:54:40 +08:00
parent ddd6adbcf7
commit a8620e133a
17 changed files with 717 additions and 121 deletions

View File

@@ -1052,7 +1052,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
except httpx.HTTPStatusError as e2: except httpx.HTTPStatusError as e2:
error_body = "" error_body = ""
try: try:
error_body = resp.text[:4000] if resp.text else "" if envelope and hasattr(envelope, "extract_error_text"):
error_body = await envelope.extract_error_text(resp)
else:
error_body = resp.text[:4000] if resp.text else ""
except Exception: except Exception:
error_body = "" error_body = ""
e2.upstream_response = error_body # type: ignore[attr-defined] e2.upstream_response = error_body # type: ignore[attr-defined]
@@ -1060,7 +1063,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
else: else:
error_body = "" error_body = ""
try: try:
error_body = resp.text[:4000] if resp.text else "" if envelope and hasattr(envelope, "extract_error_text"):
error_body = await envelope.extract_error_text(resp)
else:
error_body = resp.text[:4000] if resp.text else ""
except Exception: except Exception:
error_body = "" error_body = ""
e.upstream_response = error_body # type: ignore[attr-defined] e.upstream_response = error_body # type: ignore[attr-defined]
@@ -1325,7 +1331,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
from src.api.handlers.base.chat_sync_executor import ChatSyncExecutor from src.api.handlers.base.chat_sync_executor import ChatSyncExecutor
error_text = await ChatSyncExecutor(self)._extract_error_text(e) error_text = await ChatSyncExecutor(self)._extract_error_text(
e,
envelope=envelope,
)
try: try:
if response_ctx is not None: if response_ctx is not None:

View File

@@ -675,7 +675,10 @@ class ChatSyncExecutor:
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
error_body = "" error_body = ""
try: try:
error_body = resp.text[:4000] if resp.text else "" if envelope and hasattr(envelope, "extract_error_text"):
error_body = await envelope.extract_error_text(resp)
else:
error_body = resp.text[:4000] if resp.text else ""
except Exception: except Exception:
error_body = "" error_body = ""
# 供 ErrorClassifier 优先读取 # 供 ErrorClassifier 优先读取
@@ -808,8 +811,15 @@ class ChatSyncExecutor:
request_metadata=stream_fail_metadata, request_metadata=stream_fail_metadata,
) )
async def _extract_error_text(self, e: httpx.HTTPStatusError) -> str: async def _extract_error_text(
self,
e: httpx.HTTPStatusError,
*,
envelope: Any = None,
) -> str:
"""从 HTTP 错误中提取错误文本""" """从 HTTP 错误中提取错误文本"""
if envelope and hasattr(envelope, "extract_error_text"):
return await envelope.extract_error_text(e)
try: try:
if hasattr(e.response, "is_stream_consumed") and not e.response.is_stream_consumed: if hasattr(e.response, "is_stream_consumed") and not e.response.is_stream_consumed:
error_bytes = await e.response.aread() error_bytes = await e.response.aread()

View File

@@ -432,7 +432,10 @@ class CliStreamMixin:
except httpx.HTTPStatusError as e2: except httpx.HTTPStatusError as e2:
error_body = "" error_body = ""
try: try:
error_body = resp.text[:4000] if resp.text else "" if envelope and hasattr(envelope, "extract_error_text"):
error_body = await envelope.extract_error_text(resp)
else:
error_body = resp.text[:4000] if resp.text else ""
except Exception: except Exception:
error_body = "" error_body = ""
e2.upstream_response = error_body # type: ignore[attr-defined] e2.upstream_response = error_body # type: ignore[attr-defined]
@@ -440,7 +443,10 @@ class CliStreamMixin:
else: else:
error_body = "" error_body = ""
try: try:
error_body = resp.text[:4000] if resp.text else "" if envelope and hasattr(envelope, "extract_error_text"):
error_body = await envelope.extract_error_text(resp)
else:
error_body = resp.text[:4000] if resp.text else ""
except Exception: except Exception:
error_body = "" error_body = ""
e.upstream_response = error_body # type: ignore[attr-defined] e.upstream_response = error_body # type: ignore[attr-defined]
@@ -694,7 +700,10 @@ class CliStreamMixin:
response_ctx = None response_ctx = None
continue continue
error_text = await self._extract_error_text(e) error_text = await self._extract_error_text(
e,
envelope=envelope,
)
try: try:
if response_ctx is not None: if response_ctx is not None:

View File

@@ -311,7 +311,10 @@ class CliSyncMixin:
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
error_body = "" error_body = ""
try: try:
error_body = resp.text[:4000] if resp.text else "" if envelope and hasattr(envelope, "extract_error_text"):
error_body = await envelope.extract_error_text(resp)
else:
error_body = resp.text[:4000] if resp.text else ""
except Exception: except Exception:
error_body = "" error_body = ""
e.upstream_response = error_body # type: ignore[attr-defined] e.upstream_response = error_body # type: ignore[attr-defined]
@@ -583,8 +586,15 @@ class CliSyncMixin:
raise raise
async def _extract_error_text(self, e: httpx.HTTPStatusError) -> str: async def _extract_error_text(
self,
e: httpx.HTTPStatusError,
*,
envelope: Any = None,
) -> str:
"""从 HTTP 错误中提取错误文本""" """从 HTTP 错误中提取错误文本"""
if envelope and hasattr(envelope, "extract_error_text"):
return await envelope.extract_error_text(e)
try: try:
if hasattr(e.response, "is_stream_consumed") and not e.response.is_stream_consumed: if hasattr(e.response, "is_stream_consumed") and not e.response.is_stream_consumed:
error_bytes = await e.response.aread() error_bytes = await e.response.aread()

View File

@@ -380,22 +380,21 @@ class HandlerAdapterBase(ApiAdapter):
is_kiro = provider_type == ProviderType.KIRO is_kiro = provider_type == ProviderType.KIRO
is_oauth = auth_type == "oauth" is_oauth = auth_type == "oauth"
vertex_auth_info: Any | None = None vertex_auth_info: Any | None = None
kiro_cfg: Any | None = None
if is_kiro:
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
kiro_cfg = KiroAuthConfig.from_dict(decrypted_auth_config or {})
# ---- URL ---- # ---- URL ----
if is_kiro: if is_kiro:
from src.services.provider.adapters.kiro.constants import ( from src.services.provider.adapters.kiro.request import (
KIRO_GENERATE_ASSISTANT_PATH, build_kiro_generate_assistant_url,
) )
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
_kiro_cfg = KiroAuthConfig.from_dict(decrypted_auth_config or {}) assert kiro_cfg is not None
region = _kiro_cfg.effective_api_region() url = build_kiro_generate_assistant_url(validated_base_url, cfg=kiro_cfg)
effective_base_url = (
validated_base_url.replace("{region}", region)
if "{region}" in validated_base_url
else validated_base_url
)
url = f"{str(effective_base_url).rstrip('/')}{KIRO_GENERATE_ASSISTANT_PATH}"
elif is_antigravity: elif is_antigravity:
from src.services.provider.adapters.antigravity.constants import ( from src.services.provider.adapters.antigravity.constants import (
V1INTERNAL_PATH_TEMPLATE, V1INTERNAL_PATH_TEMPLATE,
@@ -463,22 +462,14 @@ class HandlerAdapterBase(ApiAdapter):
merged_extra.update(get_v1internal_extra_headers()) merged_extra.update(get_v1internal_extra_headers())
if is_kiro: if is_kiro:
from src.services.provider.adapters.kiro.headers import ( from src.services.provider.adapters.kiro.request import (
build_generate_assistant_headers, build_kiro_request_headers,
) )
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
from src.services.provider.adapters.kiro.token_manager import generate_machine_id
kiro_cfg = KiroAuthConfig.from_dict(decrypted_auth_config or {}) assert kiro_cfg is not None
region = kiro_cfg.effective_api_region() kiro_headers = build_kiro_request_headers(
machine_id = generate_machine_id(kiro_cfg) kiro_cfg,
kiro_headers = build_generate_assistant_headers(
host=f"q.{region}.amazonaws.com",
access_token=api_key, access_token=api_key,
machine_id=machine_id,
kiro_version=kiro_cfg.kiro_version,
system_version=kiro_cfg.system_version,
node_version=kiro_cfg.node_version,
) )
merged_extra.update(kiro_headers) merged_extra.update(kiro_headers)
@@ -540,18 +531,17 @@ class HandlerAdapterBase(ApiAdapter):
) )
if is_kiro: if is_kiro:
from src.services.provider.adapters.kiro.converter import ( from src.services.provider.adapters.kiro.request import (
convert_claude_messages_to_conversation_state, build_kiro_request_payload,
) )
assert kiro_cfg is not None
effective_model = model_name or request_data.get("model", "") effective_model = model_name or request_data.get("model", "")
conversation_state = convert_claude_messages_to_conversation_state( body = build_kiro_request_payload(
body, body,
model=effective_model, model=effective_model,
cfg=kiro_cfg,
) )
body = {"conversationState": conversation_state}
if isinstance(kiro_cfg.profile_arn, str) and kiro_cfg.profile_arn.strip():
body["profileArn"] = kiro_cfg.profile_arn.strip()
# ---- Header Rules ---- # ---- Header Rules ----
if header_rules: if header_rules:

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import importlib
import json import json
import random import random
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
@@ -15,9 +16,7 @@ from src.core.provider_types import ProviderType
_ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token" _ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json" _GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
_OPENAI_ACCOUNTS_CHECK_URL = ( _OPENAI_ACCOUNTS_CHECK_URL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
"https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
)
def _coerce_proxy_url(proxy_config: dict[str, Any] | None) -> str | None: def _coerce_proxy_url(proxy_config: dict[str, Any] | None) -> str | None:
@@ -58,15 +57,11 @@ def _inject_auth_into_url(url: str, username: str, password: str | None = None)
if parsed.port: if parsed.port:
host_part = f"{host_part}:{parsed.port}" host_part = f"{host_part}:{parsed.port}"
auth_part = ( auth_part = (
f"{encoded_username}:{encoded_password}" f"{encoded_username}:{encoded_password}" if encoded_password else encoded_username
if encoded_password
else encoded_username
) )
netloc = f"{auth_part}@{host_part}" netloc = f"{auth_part}@{host_part}"
return urlunsplit( return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
(parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)
)
except Exception: except Exception:
return url return url
@@ -114,6 +109,15 @@ def _format_exc_chain(e: BaseException) -> str:
return " <- ".join(parts) return " <- ".join(parts)
def _load_optional_attr(module_name: str, attr_name: str) -> Any | None:
"""按需加载跨层 helper避免 core 层产生静态 services import。"""
try:
module = importlib.import_module(module_name)
except (ImportError, ModuleNotFoundError):
return None
return getattr(module, attr_name, None)
async def _httpx_post( async def _httpx_post(
url: str, url: str,
*, *,
@@ -501,9 +505,12 @@ async def fetch_openai_account_name(
proxy_url = _coerce_proxy_url(proxy_config) proxy_url = _coerce_proxy_url(proxy_config)
if not proxy_url and proxy_config: if not proxy_url and proxy_config:
try: try:
from src.services.proxy_node.resolver import build_proxy_url_async build_proxy_url_async = _load_optional_attr(
"src.services.proxy_node.resolver",
proxy_url = await build_proxy_url_async(proxy_config) "build_proxy_url_async",
)
if callable(build_proxy_url_async):
proxy_url = await build_proxy_url_async(proxy_config)
except Exception: except Exception:
proxy_url = None proxy_url = None
@@ -527,9 +534,7 @@ async def fetch_openai_account_name(
} }
for attempt in range(3): for attempt in range(3):
if attempt: if attempt:
await asyncio.sleep( await asyncio.sleep([1.0, 2.0][attempt - 1] + random.uniform(0.5, 1.5))
[1.0, 2.0][attempt - 1] + random.uniform(0.5, 1.5)
)
resp = await session.get(_OPENAI_ACCOUNTS_CHECK_URL, headers=headers) resp = await session.get(_OPENAI_ACCOUNTS_CHECK_URL, headers=headers)
if 200 <= resp.status_code < 300: if 200 <= resp.status_code < 300:
return _extract_openai_account_name(resp.json(), account_id) return _extract_openai_account_name(resp.json(), account_id)
@@ -638,10 +643,14 @@ async def enrich_auth_config(
为支持按需 bootstrap这里会尝试按 provider_type 触发插件注册。 为支持按需 bootstrap这里会尝试按 provider_type 触发插件注册。
""" """
from src.core.provider_types import normalize_provider_type from src.core.provider_types import normalize_provider_type
from src.services.provider.envelope import ensure_providers_bootstrapped
pt = normalize_provider_type(provider_type) pt = normalize_provider_type(provider_type)
ensure_providers_bootstrapped(provider_types=[pt] if pt else None) ensure_providers_bootstrapped = _load_optional_attr(
"src.services.provider.envelope",
"ensure_providers_bootstrapped",
)
if callable(ensure_providers_bootstrapped):
ensure_providers_bootstrapped(provider_types=[pt] if pt else None)
enricher = _auth_enrichers.get(pt) enricher = _auth_enrichers.get(pt)
if enricher: if enricher:
return await enricher(auth_config, token_response, access_token, proxy_config) return await enricher(auth_config, token_response, access_token, proxy_config)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import contextvars import contextvars
from dataclasses import dataclass from dataclasses import dataclass, replace
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -19,6 +19,10 @@ class KiroRequestContext:
system_version: str | None = None system_version: str | None = None
node_version: str | None = None node_version: str | None = None
thinking_enabled: bool = False thinking_enabled: bool = False
last_http_status: int | None = None
last_http_error_category: str | None = None
last_connection_error_category: str | None = None
last_connection_error_summary: str | None = None
_kiro_request_context: contextvars.ContextVar[KiroRequestContext | None] = contextvars.ContextVar( _kiro_request_context: contextvars.ContextVar[KiroRequestContext | None] = contextvars.ContextVar(
@@ -35,8 +39,44 @@ def get_kiro_request_context() -> KiroRequestContext | None:
return _kiro_request_context.get() return _kiro_request_context.get()
def update_kiro_http_status(
*,
status_code: int,
category: str,
) -> None:
ctx = get_kiro_request_context()
if ctx is None:
return
set_kiro_request_context(
replace(
ctx,
last_http_status=int(status_code),
last_http_error_category=str(category),
)
)
def update_kiro_connection_error(
*,
category: str,
summary: str,
) -> None:
ctx = get_kiro_request_context()
if ctx is None:
return
set_kiro_request_context(
replace(
ctx,
last_connection_error_category=str(category),
last_connection_error_summary=str(summary),
)
)
__all__ = [ __all__ = [
"KiroRequestContext", "KiroRequestContext",
"get_kiro_request_context", "get_kiro_request_context",
"set_kiro_request_context", "set_kiro_request_context",
"update_kiro_connection_error",
"update_kiro_http_status",
] ]

View File

@@ -12,26 +12,21 @@ from __future__ import annotations
from typing import Any from typing import Any
from src.core.logger import logger
from src.services.provider.adapters.kiro.context import KiroRequestContext, set_kiro_request_context from src.services.provider.adapters.kiro.context import KiroRequestContext, set_kiro_request_context
from src.services.provider.adapters.kiro.converter import ( from src.services.provider.adapters.kiro.error_enhancer import (
convert_claude_messages_to_conversation_state, classify_kiro_connection_error,
classify_kiro_http_status,
extract_kiro_http_error_text,
summarize_kiro_connection_error,
) )
from src.services.provider.adapters.kiro.headers import build_generate_assistant_headers from src.services.provider.adapters.kiro.headers import build_generate_assistant_headers
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
from src.services.provider.adapters.kiro.token_manager import generate_machine_id from src.services.provider.adapters.kiro.request import (
build_kiro_request_context,
build_kiro_request_payload,
def _resolve_region(cfg: KiroAuthConfig) -> str: )
"""解析 API 服务端点的 regionq.{region}.amazonaws.com""" from src.services.provider.request_context import get_selected_base_url
return cfg.effective_api_region()
def _is_thinking_enabled(request_body: dict[str, Any]) -> bool:
thinking = request_body.get("thinking")
if not isinstance(thinking, dict):
return False
ttype = str(thinking.get("type") or "").strip().lower()
return ttype in {"enabled", "adaptive"}
class KiroEnvelope: class KiroEnvelope:
@@ -63,34 +58,13 @@ class KiroEnvelope:
decrypted_auth_config: dict[str, Any] | None, decrypted_auth_config: dict[str, Any] | None,
) -> tuple[dict[str, Any], str | None]: ) -> tuple[dict[str, Any], str | None]:
cfg = KiroAuthConfig.from_dict(decrypted_auth_config or {}) cfg = KiroAuthConfig.from_dict(decrypted_auth_config or {})
set_kiro_request_context(build_kiro_request_context(request_body, cfg=cfg))
region = _resolve_region(cfg) wrapped = build_kiro_request_payload(
machine_id = generate_machine_id(cfg)
thinking_enabled = _is_thinking_enabled(request_body)
set_kiro_request_context(
KiroRequestContext(
region=region,
machine_id=machine_id,
kiro_version=cfg.kiro_version,
system_version=cfg.system_version,
node_version=cfg.node_version,
thinking_enabled=thinking_enabled,
)
)
conversation_state = convert_claude_messages_to_conversation_state(
request_body, request_body,
model=model, model=model,
cfg=cfg,
) )
wrapped: dict[str, Any] = {
"conversationState": conversation_state,
}
if isinstance(cfg.profile_arn, str) and cfg.profile_arn.strip():
wrapped["profileArn"] = cfg.profile_arn.strip()
return wrapped, url_model return wrapped, url_model
def unwrap_response(self, data: Any) -> Any: def unwrap_response(self, data: Any) -> Any:
@@ -100,18 +74,46 @@ class KiroEnvelope:
return return
def capture_selected_base_url(self) -> str | None: def capture_selected_base_url(self) -> str | None:
return None return get_selected_base_url()
def on_http_status(self, *, base_url: str | None, status_code: int) -> None: # noqa: ARG002 def on_http_status(self, *, base_url: str | None, status_code: int) -> None:
return from src.services.provider.adapters.kiro.context import update_kiro_http_status
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None: # noqa: ARG002 category = classify_kiro_http_status(status_code)
return update_kiro_http_status(status_code=status_code, category=category)
if status_code >= 400:
logger.warning(
"kiro upstream http status: status={}, category={}, base_url={}",
status_code,
category,
base_url or "-",
)
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None:
from src.services.provider.adapters.kiro.context import update_kiro_connection_error
category = classify_kiro_connection_error(exc)
summary = summarize_kiro_connection_error(exc)
update_kiro_connection_error(category=category, summary=summary)
logger.warning(
"kiro upstream connection error: category={}, base_url={}, error={}",
category,
base_url or "-",
summary,
)
def force_stream_rewrite(self) -> bool: def force_stream_rewrite(self) -> bool:
# Kiro streaming is binary AWS Event Stream and must be rewritten. # Kiro streaming is binary AWS Event Stream and must be rewritten.
return True return True
async def extract_error_text(
self,
source: Any,
*,
limit: int = 4000,
) -> str:
return await extract_kiro_http_error_text(source, limit=limit)
kiro_envelope = KiroEnvelope() kiro_envelope = KiroEnvelope()

View File

@@ -0,0 +1,175 @@
"""Kiro HTTP/network error classification helpers."""
from __future__ import annotations
import json
import httpx
_KNOWN_REASON_MESSAGES: dict[str, str] = {
"CONTENT_LENGTH_EXCEEDS_THRESHOLD": "输入超过模型上下文限制",
"MONTHLY_REQUEST_COUNT": "账户已达到月度请求配额",
}
def classify_kiro_http_status(status_code: int) -> str:
"""Classify upstream HTTP status into stable buckets."""
if 200 <= status_code < 300:
return "ok"
if status_code in {401, 403}:
return "auth_error"
if status_code == 429:
return "rate_limited"
if status_code in {408, 504}:
return "timeout"
if 500 <= status_code < 600:
return "upstream_server_error"
if 400 <= status_code < 500:
return "upstream_client_error"
return "unexpected_status"
def classify_kiro_connection_error(exc: Exception) -> str:
"""Classify transport exceptions raised by httpx."""
if isinstance(exc, httpx.ConnectTimeout):
return "connect_timeout"
if isinstance(exc, httpx.ReadTimeout):
return "read_timeout"
if isinstance(exc, httpx.WriteTimeout):
return "write_timeout"
if isinstance(exc, httpx.PoolTimeout):
return "pool_timeout"
if isinstance(exc, httpx.TimeoutException):
return "timeout"
if isinstance(exc, httpx.ConnectError):
return "connect_error"
return "network_error"
def summarize_kiro_connection_error(exc: Exception) -> str:
"""Build a compact diagnostic string safe for logs/errors."""
category = classify_kiro_connection_error(exc)
detail = str(exc).strip()
if len(detail) > 200:
detail = detail[:200]
if detail:
return f"{category}: {type(exc).__name__}: {detail}"
return f"{category}: {type(exc).__name__}"
def build_kiro_network_diagnostic(
*,
http_status: int | None,
http_category: str | None,
connection_summary: str | None,
) -> str | None:
"""Build short supplemental diagnostic text for user-facing error paths."""
if connection_summary:
return f"network={connection_summary}"
if http_status is None:
return None
category = str(http_category or "unknown").strip() or "unknown"
return f"http_status={http_status} ({category})"
def parse_kiro_error_text(raw_text: str | None) -> dict[str, str]:
result = {
"type": "",
"reason": "",
"message": "",
"raw": str(raw_text or "").strip(),
}
if not result["raw"]:
return result
try:
data = json.loads(result["raw"])
except Exception:
result["message"] = result["raw"]
return result
error_obj = data.get("error")
if isinstance(error_obj, dict):
result["type"] = str(error_obj.get("type") or error_obj.get("__type") or "").strip()
result["reason"] = str(error_obj.get("reason") or error_obj.get("code") or "").strip()
message = error_obj.get("message")
if isinstance(message, str) and message.strip():
result["message"] = message.strip()
if not result["message"]:
message = data.get("message")
if isinstance(message, str) and message.strip():
result["message"] = message.strip()
if not result["reason"]:
reason = data.get("reason") or data.get("code")
if isinstance(reason, str) and reason.strip():
result["reason"] = reason.strip()
if not result["message"]:
result["message"] = result["raw"]
return result
def enhance_kiro_http_error_text(
raw_text: str | None,
*,
status_code: int | None = None,
) -> str:
parsed = parse_kiro_error_text(raw_text)
reason = parsed["reason"].upper()
type_name = parsed["type"]
message = parsed["message"]
friendly_message = _KNOWN_REASON_MESSAGES.get(reason)
if friendly_message:
message = friendly_message
elif status_code == 403 and "access denied" in message.lower():
message = "Kiro 账户权限被拒绝"
elif status_code == 429 and not reason:
message = "Kiro 请求过于频繁,请稍后重试"
parts: list[str] = []
if type_name:
parts.append(type_name)
if reason:
parts.append(f"[{reason}]")
if message:
parts.append(message)
return ": ".join(parts) if parts else parsed["raw"]
async def extract_kiro_http_error_text(
source: httpx.Response | httpx.HTTPStatusError,
*,
limit: int = 4000,
) -> str:
response = source.response if isinstance(source, httpx.HTTPStatusError) else source
raw_text = ""
try:
if hasattr(response, "is_stream_consumed") and not response.is_stream_consumed:
error_bytes = await response.aread()
raw_text = error_bytes.decode("utf-8", errors="replace")
else:
raw_text = response.text if hasattr(response, "_content") else ""
except Exception as exc:
return f"Unable to read Kiro error response: {exc}"
raw_text = (raw_text or "")[:limit]
if not raw_text:
return ""
return enhance_kiro_http_error_text(raw_text, status_code=response.status_code)
__all__ = [
"build_kiro_network_diagnostic",
"classify_kiro_connection_error",
"classify_kiro_http_status",
"enhance_kiro_http_error_text",
"extract_kiro_http_error_text",
"parse_kiro_error_text",
"summarize_kiro_connection_error",
]

View File

@@ -19,6 +19,7 @@ from typing import Any
from src.core.logger import logger from src.core.logger import logger
from src.services.provider.adapters.kiro.constants import CONTEXT_WINDOW_TOKENS from src.services.provider.adapters.kiro.constants import CONTEXT_WINDOW_TOKENS
from src.services.provider.adapters.kiro.error_enhancer import build_kiro_network_diagnostic
from src.services.provider.adapters.kiro.parser.decoder import EventStreamDecoder from src.services.provider.adapters.kiro.parser.decoder import EventStreamDecoder
# Safety limit for thinking_buffer to prevent memory exhaustion from # Safety limit for thinking_buffer to prevent memory exhaustion from
@@ -582,6 +583,21 @@ async def rewrite_eventstream_to_sse(
error_message = f"Kiro API error: {upstream_msg}" error_message = f"Kiro API error: {upstream_msg}"
except Exception: except Exception:
pass pass
try:
from src.services.provider.adapters.kiro.context import get_kiro_request_context
kiro_ctx = get_kiro_request_context()
diag = build_kiro_network_diagnostic(
http_status=kiro_ctx.last_http_status if kiro_ctx else None,
http_category=kiro_ctx.last_http_error_category if kiro_ctx else None,
connection_summary=(
kiro_ctx.last_connection_error_summary if kiro_ctx else None
),
)
if diag:
error_message = f"{error_message} | {diag}"
except Exception:
pass
yield _sse_data_bytes( yield _sse_data_bytes(
{ {
"type": "error", "type": "error",

View File

@@ -16,11 +16,13 @@ from __future__ import annotations
from typing import Any from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
from src.services.provider.adapters.kiro.constants import ( from src.services.provider.adapters.kiro.constants import DEFAULT_REGION
DEFAULT_REGION,
KIRO_GENERATE_ASSISTANT_PATH,
)
from src.services.provider.adapters.kiro.context import get_kiro_request_context from src.services.provider.adapters.kiro.context import get_kiro_request_context
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
from src.services.provider.adapters.kiro.request import (
build_kiro_generate_assistant_url,
resolve_kiro_base_url,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Preset model catalog # Preset model catalog
@@ -28,6 +30,7 @@ from src.services.provider.adapters.kiro.context import get_kiro_request_context
# Kiro upstream has no /v1/models endpoint. We use the unified preset models # Kiro upstream has no /v1/models endpoint. We use the unified preset models
# registry from preset_models.py. # registry from preset_models.py.
from src.services.provider.preset_models import create_preset_models_fetcher from src.services.provider.preset_models import create_preset_models_fetcher
from src.services.provider.request_context import set_selected_base_url
fetch_models_kiro = create_preset_models_fetcher("kiro") fetch_models_kiro = create_preset_models_fetcher("kiro")
@@ -51,15 +54,14 @@ def build_kiro_url(
""" """
_ = is_stream _ = is_stream
base = str(getattr(endpoint, "base_url", "") or "").rstrip("/")
ctx = get_kiro_request_context() ctx = get_kiro_request_context()
region = (ctx.region if ctx else "") or DEFAULT_REGION region = (ctx.region if ctx else "") or DEFAULT_REGION
if "{region}" in base: raw_base = str(getattr(endpoint, "base_url", "") or "").rstrip("/")
base = base.replace("{region}", region) cfg = KiroAuthConfig(api_region=region)
base = resolve_kiro_base_url(raw_base, cfg=cfg)
set_selected_base_url(base)
path = KIRO_GENERATE_ASSISTANT_PATH url = build_kiro_generate_assistant_url(raw_base, cfg=cfg)
url = base if base.endswith(path) else f"{base}{path}"
if effective_query_params: if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True) query_string = urlencode(effective_query_params, doseq=True)

View File

@@ -0,0 +1,148 @@
"""Helpers for building Kiro generateAssistantResponse requests."""
from __future__ import annotations
from typing import Any
from src.services.provider.adapters.kiro.constants import KIRO_GENERATE_ASSISTANT_PATH
from src.services.provider.adapters.kiro.context import KiroRequestContext
from src.services.provider.adapters.kiro.converter import (
convert_claude_messages_to_conversation_state,
)
from src.services.provider.adapters.kiro.headers import build_generate_assistant_headers
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
from src.services.provider.adapters.kiro.token_manager import generate_machine_id
def is_kiro_thinking_enabled(request_body: dict[str, Any]) -> bool:
thinking = request_body.get("thinking")
if not isinstance(thinking, dict):
return False
ttype = str(thinking.get("type") or "").strip().lower()
return ttype in {"enabled", "adaptive"}
def build_kiro_request_context(
request_body: dict[str, Any],
*,
cfg: KiroAuthConfig,
) -> KiroRequestContext:
return KiroRequestContext(
region=cfg.effective_api_region(),
machine_id=generate_machine_id(cfg),
kiro_version=cfg.kiro_version,
system_version=cfg.system_version,
node_version=cfg.node_version,
thinking_enabled=is_kiro_thinking_enabled(request_body),
)
def build_kiro_request_headers(
cfg: KiroAuthConfig,
*,
access_token: str | None = None,
) -> dict[str, str]:
region = cfg.effective_api_region()
host = f"q.{region}.amazonaws.com"
return build_generate_assistant_headers(
host=host,
access_token=access_token,
machine_id=generate_machine_id(cfg),
kiro_version=cfg.kiro_version,
system_version=cfg.system_version,
node_version=cfg.node_version,
)
def resolve_kiro_base_url(base_url: str, *, cfg: KiroAuthConfig) -> str:
resolved = str(base_url or "").rstrip("/")
region = cfg.effective_api_region()
if "{region}" in resolved:
resolved = resolved.replace("{region}", region)
return resolved
def build_kiro_generate_assistant_url(base_url: str, *, cfg: KiroAuthConfig) -> str:
resolved = resolve_kiro_base_url(base_url, cfg=cfg)
if resolved.endswith(KIRO_GENERATE_ASSISTANT_PATH):
return resolved
return f"{resolved}{KIRO_GENERATE_ASSISTANT_PATH}"
def build_kiro_inference_config(request_body: dict[str, Any]) -> dict[str, Any] | None:
inference_config: dict[str, Any] = {}
max_tokens = request_body.get("max_tokens")
try:
max_tokens_i = int(max_tokens) if max_tokens is not None else 0
except Exception:
max_tokens_i = 0
if max_tokens_i > 0:
inference_config["maxTokens"] = max_tokens_i
temperature = request_body.get("temperature")
try:
temperature_f = float(temperature) if temperature is not None else None
except Exception:
temperature_f = None
if temperature_f is not None and temperature_f >= 0:
inference_config["temperature"] = temperature_f
top_p = request_body.get("top_p")
try:
top_p_f = float(top_p) if top_p is not None else None
except Exception:
top_p_f = None
if top_p_f is not None and top_p_f > 0:
inference_config["topP"] = top_p_f
return inference_config or None
def get_profile_arn_for_payload(cfg: KiroAuthConfig) -> str | None:
profile_arn = str(cfg.profile_arn or "").strip()
if not profile_arn:
return None
from src.services.provider.adapters.kiro.models.credentials import _normalize_auth_method
if _normalize_auth_method(cfg.auth_method) == "idc":
return None
return profile_arn
def build_kiro_request_payload(
request_body: dict[str, Any],
*,
model: str,
cfg: KiroAuthConfig,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"conversationState": convert_claude_messages_to_conversation_state(
request_body,
model=model,
)
}
inference_config = build_kiro_inference_config(request_body)
if inference_config:
payload["inferenceConfig"] = inference_config
profile_arn = get_profile_arn_for_payload(cfg)
if profile_arn:
payload["profileArn"] = profile_arn
return payload
__all__ = [
"build_kiro_generate_assistant_url",
"build_kiro_inference_config",
"build_kiro_request_context",
"build_kiro_request_headers",
"build_kiro_request_payload",
"get_profile_arn_for_payload",
"is_kiro_thinking_enabled",
"resolve_kiro_base_url",
]

View File

@@ -21,6 +21,7 @@ from src.services.provider.adapters.kiro.models.usage_limits import (
calculate_current_usage, calculate_current_usage,
calculate_total_usage_limit, calculate_total_usage_limit,
) )
from src.services.provider.adapters.kiro.request import get_profile_arn_for_payload
from src.services.provider.adapters.kiro.token_manager import ( from src.services.provider.adapters.kiro.token_manager import (
generate_machine_id, generate_machine_id,
is_token_expired, is_token_expired,
@@ -89,7 +90,7 @@ async def fetch_kiro_usage_limits(
# 构建 URL添加 isEmailRequired=true 获取邮箱) # 构建 URL添加 isEmailRequired=true 获取邮箱)
url = f"https://{host}/getUsageLimits?origin=AI_EDITOR&resourceType=AGENTIC_REQUEST&isEmailRequired=true" url = f"https://{host}/getUsageLimits?origin=AI_EDITOR&resourceType=AGENTIC_REQUEST&isEmailRequired=true"
profile_arn = effective_cfg.profile_arn profile_arn = get_profile_arn_for_payload(effective_cfg)
if profile_arn: if profile_arn:
from urllib.parse import quote from urllib.parse import quote

View File

@@ -87,6 +87,19 @@ class ProviderEnvelope(Protocol):
with client original headers. Return an empty frozenset to keep all. with client original headers. Return an empty frozenset to keep all.
""" """
async def extract_error_text(
self,
source: Any,
*,
limit: int = 4000,
) -> str:
"""Extract error text from upstream HTTP error response.
``source`` is either an ``httpx.Response`` or ``httpx.HTTPStatusError``.
Default behavior (when not overridden) is handled by the caller.
Implementations may parse provider-specific error formats.
"""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Envelope Registry # Envelope Registry

7
tests/conftest.py Normal file
View File

@@ -0,0 +1,7 @@
from __future__ import annotations
import os
# 测试运行在容器里时默认会被识别为 production这里提供稳定的测试密钥。
os.environ.setdefault("JWT_SECRET_KEY", "test-jwt-secret-key-for-pytest-1234567890")
os.environ.setdefault("ENCRYPTION_KEY", "test-encryption-key-for-pytest-1234567890")

View File

@@ -0,0 +1,150 @@
from __future__ import annotations
from types import SimpleNamespace
import httpx
import pytest
from src.services.provider.adapters.kiro.context import (
KiroRequestContext,
get_kiro_request_context,
set_kiro_request_context,
)
from src.services.provider.adapters.kiro.envelope import kiro_envelope
from src.services.provider.adapters.kiro.error_enhancer import (
build_kiro_network_diagnostic,
classify_kiro_http_status,
enhance_kiro_http_error_text,
)
from src.services.provider.adapters.kiro.plugin import build_kiro_url
from src.services.provider.request_context import get_selected_base_url, set_selected_base_url
_REFRESH_TOKEN = "r" * 120
@pytest.fixture(autouse=True)
def _reset_kiro_context() -> None: # type: ignore[misc]
set_kiro_request_context(None)
set_selected_base_url(None)
yield
set_kiro_request_context(None)
set_selected_base_url(None)
def test_kiro_http_status_classification() -> None:
assert classify_kiro_http_status(200) == "ok"
assert classify_kiro_http_status(401) == "auth_error"
assert classify_kiro_http_status(429) == "rate_limited"
assert classify_kiro_http_status(503) == "upstream_server_error"
def test_kiro_envelope_records_http_status() -> None:
set_kiro_request_context(KiroRequestContext(region="us-east-1", machine_id="mid-1"))
kiro_envelope.on_http_status(base_url="https://q.us-east-1.amazonaws.com", status_code=429)
ctx = get_kiro_request_context()
assert ctx is not None
assert ctx.last_http_status == 429
assert ctx.last_http_error_category == "rate_limited"
def test_kiro_envelope_records_connection_error_summary() -> None:
set_kiro_request_context(KiroRequestContext(region="us-east-1", machine_id="mid-1"))
kiro_envelope.on_connection_error(
base_url="https://q.us-east-1.amazonaws.com",
exc=httpx.ConnectTimeout("dial timed out"),
)
ctx = get_kiro_request_context()
assert ctx is not None
assert ctx.last_connection_error_category == "connect_timeout"
assert ctx.last_connection_error_summary is not None
assert "ConnectTimeout" in ctx.last_connection_error_summary
def test_kiro_envelope_capture_selected_base_url() -> None:
set_selected_base_url("https://q.us-west-2.amazonaws.com")
assert kiro_envelope.capture_selected_base_url() == "https://q.us-west-2.amazonaws.com"
def test_build_kiro_url_sets_selected_base_url_and_applies_region() -> None:
set_kiro_request_context(KiroRequestContext(region="eu-west-1", machine_id="mid-2"))
endpoint = SimpleNamespace(base_url="https://q.{region}.amazonaws.com")
url = build_kiro_url(
endpoint,
is_stream=True,
effective_query_params={"alt": "sse"},
)
assert url.startswith("https://q.eu-west-1.amazonaws.com/generateAssistantResponse")
assert get_selected_base_url() == "https://q.eu-west-1.amazonaws.com"
def test_build_kiro_network_diagnostic_prefers_connection_summary() -> None:
diag = build_kiro_network_diagnostic(
http_status=503,
http_category="upstream_server_error",
connection_summary="connect_timeout: ConnectTimeout: dial timed out",
)
assert diag == "network=connect_timeout: ConnectTimeout: dial timed out"
def test_kiro_envelope_wrap_request_adds_inference_config() -> None:
wrapped, _ = kiro_envelope.wrap_request(
{
"model": "claude-sonnet-4-5",
"max_tokens": 2048,
"temperature": 0.3,
"top_p": 0.8,
"messages": [{"role": "user", "content": "hello"}],
},
model="claude-sonnet-4-5",
url_model=None,
decrypted_auth_config={
"auth_method": "social",
"refreshToken": _REFRESH_TOKEN,
"profileArn": "arn:aws:iam::1:role/x",
},
)
assert wrapped["conversationState"]["currentMessage"]["userInputMessage"]["modelId"] == (
"claude-sonnet-4-5"
)
assert wrapped["inferenceConfig"] == {
"maxTokens": 2048,
"temperature": 0.3,
"topP": 0.8,
}
assert wrapped["profileArn"] == "arn:aws:iam::1:role/x"
def test_kiro_envelope_omits_profile_arn_for_idc_auth() -> None:
wrapped, _ = kiro_envelope.wrap_request(
{
"messages": [{"role": "user", "content": "hello"}],
},
model="claude-sonnet-4-5",
url_model=None,
decrypted_auth_config={
"auth_method": "identity_center",
"refreshToken": _REFRESH_TOKEN,
"profileArn": "arn:aws:iam::1:role/x",
"clientId": "cid",
"clientSecret": "secret",
},
)
assert "profileArn" not in wrapped
def test_enhance_kiro_http_error_text_maps_known_reason() -> None:
text = enhance_kiro_http_error_text(
'{"message":"Input is too long.","reason":"CONTENT_LENGTH_EXCEEDS_THRESHOLD"}',
status_code=400,
)
assert "[CONTENT_LENGTH_EXCEEDS_THRESHOLD]" in text
assert "输入超过模型上下文限制" in text

View File

@@ -855,8 +855,6 @@ async def test_antigravity_refresher_success_resets_forbidden_flag(
async def test_kiro_refresher_runtime_401_marks_key_invalid( async def test_kiro_refresher_runtime_401_marks_key_invalid(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
from src.services.provider_keys.quota_refresh import kiro_refresher as module
class _Banned(Exception): class _Banned(Exception):
pass pass
@@ -881,7 +879,10 @@ async def test_kiro_refresher_runtime_401_marks_key_invalid(
"src.services.proxy_node.resolver", "src.services.proxy_node.resolver",
{"resolve_effective_proxy": lambda provider_proxy, key_proxy: None}, {"resolve_effective_proxy": lambda provider_proxy, key_proxy: None},
) )
monkeypatch.setattr(module.crypto_service, "decrypt", lambda _v: "{}") monkeypatch.setattr(
"src.services.provider_keys.quota_refresh.kiro_refresher.crypto_service.decrypt",
lambda _v: "{}",
)
db = _FakeDB() db = _FakeDB()
key = SimpleNamespace( key = SimpleNamespace(
@@ -919,8 +920,6 @@ async def test_kiro_refresher_runtime_401_marks_key_invalid(
async def test_kiro_refresher_success_updates_metadata_and_auth_config( async def test_kiro_refresher_success_updates_metadata_and_auth_config(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
from src.services.provider_keys.quota_refresh import kiro_refresher as module
class _Banned(Exception): class _Banned(Exception):
pass pass
@@ -945,8 +944,14 @@ async def test_kiro_refresher_success_updates_metadata_and_auth_config(
"src.services.proxy_node.resolver", "src.services.proxy_node.resolver",
{"resolve_effective_proxy": lambda provider_proxy, key_proxy: None}, {"resolve_effective_proxy": lambda provider_proxy, key_proxy: None},
) )
monkeypatch.setattr(module.crypto_service, "decrypt", lambda _v: json.dumps({"seed": 1})) monkeypatch.setattr(
monkeypatch.setattr(module.crypto_service, "encrypt", lambda raw: f"ENC:{raw}") "src.services.provider_keys.quota_refresh.kiro_refresher.crypto_service.decrypt",
lambda _v: json.dumps({"seed": 1}),
)
monkeypatch.setattr(
"src.services.provider_keys.quota_refresh.kiro_refresher.crypto_service.encrypt",
lambda raw: f"ENC:{raw}",
)
key = SimpleNamespace( key = SimpleNamespace(
id="k1", id="k1",