mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
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:
@@ -1052,7 +1052,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
except httpx.HTTPStatusError as e2:
|
||||
error_body = ""
|
||||
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:
|
||||
error_body = ""
|
||||
e2.upstream_response = error_body # type: ignore[attr-defined]
|
||||
@@ -1060,7 +1063,10 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
else:
|
||||
error_body = ""
|
||||
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:
|
||||
error_body = ""
|
||||
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
|
||||
|
||||
error_text = await ChatSyncExecutor(self)._extract_error_text(e)
|
||||
error_text = await ChatSyncExecutor(self)._extract_error_text(
|
||||
e,
|
||||
envelope=envelope,
|
||||
)
|
||||
|
||||
try:
|
||||
if response_ctx is not None:
|
||||
|
||||
@@ -675,7 +675,10 @@ class ChatSyncExecutor:
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_body = ""
|
||||
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:
|
||||
error_body = ""
|
||||
# 供 ErrorClassifier 优先读取
|
||||
@@ -808,8 +811,15 @@ class ChatSyncExecutor:
|
||||
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 错误中提取错误文本"""
|
||||
if envelope and hasattr(envelope, "extract_error_text"):
|
||||
return await envelope.extract_error_text(e)
|
||||
try:
|
||||
if hasattr(e.response, "is_stream_consumed") and not e.response.is_stream_consumed:
|
||||
error_bytes = await e.response.aread()
|
||||
|
||||
@@ -432,7 +432,10 @@ class CliStreamMixin:
|
||||
except httpx.HTTPStatusError as e2:
|
||||
error_body = ""
|
||||
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:
|
||||
error_body = ""
|
||||
e2.upstream_response = error_body # type: ignore[attr-defined]
|
||||
@@ -440,7 +443,10 @@ class CliStreamMixin:
|
||||
else:
|
||||
error_body = ""
|
||||
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:
|
||||
error_body = ""
|
||||
e.upstream_response = error_body # type: ignore[attr-defined]
|
||||
@@ -694,7 +700,10 @@ class CliStreamMixin:
|
||||
response_ctx = None
|
||||
continue
|
||||
|
||||
error_text = await self._extract_error_text(e)
|
||||
error_text = await self._extract_error_text(
|
||||
e,
|
||||
envelope=envelope,
|
||||
)
|
||||
|
||||
try:
|
||||
if response_ctx is not None:
|
||||
|
||||
@@ -311,7 +311,10 @@ class CliSyncMixin:
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_body = ""
|
||||
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:
|
||||
error_body = ""
|
||||
e.upstream_response = error_body # type: ignore[attr-defined]
|
||||
@@ -583,8 +586,15 @@ class CliSyncMixin:
|
||||
|
||||
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 错误中提取错误文本"""
|
||||
if envelope and hasattr(envelope, "extract_error_text"):
|
||||
return await envelope.extract_error_text(e)
|
||||
try:
|
||||
if hasattr(e.response, "is_stream_consumed") and not e.response.is_stream_consumed:
|
||||
error_bytes = await e.response.aread()
|
||||
|
||||
@@ -380,22 +380,21 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
is_kiro = provider_type == ProviderType.KIRO
|
||||
is_oauth = auth_type == "oauth"
|
||||
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 ----
|
||||
if is_kiro:
|
||||
from src.services.provider.adapters.kiro.constants import (
|
||||
KIRO_GENERATE_ASSISTANT_PATH,
|
||||
from src.services.provider.adapters.kiro.request import (
|
||||
build_kiro_generate_assistant_url,
|
||||
)
|
||||
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
|
||||
|
||||
_kiro_cfg = KiroAuthConfig.from_dict(decrypted_auth_config or {})
|
||||
region = _kiro_cfg.effective_api_region()
|
||||
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}"
|
||||
assert kiro_cfg is not None
|
||||
url = build_kiro_generate_assistant_url(validated_base_url, cfg=kiro_cfg)
|
||||
elif is_antigravity:
|
||||
from src.services.provider.adapters.antigravity.constants import (
|
||||
V1INTERNAL_PATH_TEMPLATE,
|
||||
@@ -463,22 +462,14 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
merged_extra.update(get_v1internal_extra_headers())
|
||||
|
||||
if is_kiro:
|
||||
from src.services.provider.adapters.kiro.headers import (
|
||||
build_generate_assistant_headers,
|
||||
from src.services.provider.adapters.kiro.request import (
|
||||
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 {})
|
||||
region = kiro_cfg.effective_api_region()
|
||||
machine_id = generate_machine_id(kiro_cfg)
|
||||
kiro_headers = build_generate_assistant_headers(
|
||||
host=f"q.{region}.amazonaws.com",
|
||||
assert kiro_cfg is not None
|
||||
kiro_headers = build_kiro_request_headers(
|
||||
kiro_cfg,
|
||||
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)
|
||||
|
||||
@@ -540,18 +531,17 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
)
|
||||
|
||||
if is_kiro:
|
||||
from src.services.provider.adapters.kiro.converter import (
|
||||
convert_claude_messages_to_conversation_state,
|
||||
from src.services.provider.adapters.kiro.request import (
|
||||
build_kiro_request_payload,
|
||||
)
|
||||
|
||||
assert kiro_cfg is not None
|
||||
effective_model = model_name or request_data.get("model", "")
|
||||
conversation_state = convert_claude_messages_to_conversation_state(
|
||||
body = build_kiro_request_payload(
|
||||
body,
|
||||
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 ----
|
||||
if header_rules:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import random
|
||||
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"
|
||||
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
|
||||
_OPENAI_ACCOUNTS_CHECK_URL = (
|
||||
"https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
|
||||
)
|
||||
_OPENAI_ACCOUNTS_CHECK_URL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
|
||||
|
||||
|
||||
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:
|
||||
host_part = f"{host_part}:{parsed.port}"
|
||||
auth_part = (
|
||||
f"{encoded_username}:{encoded_password}"
|
||||
if encoded_password
|
||||
else encoded_username
|
||||
f"{encoded_username}:{encoded_password}" if encoded_password else encoded_username
|
||||
)
|
||||
netloc = f"{auth_part}@{host_part}"
|
||||
|
||||
return urlunsplit(
|
||||
(parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)
|
||||
)
|
||||
return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
@@ -114,6 +109,15 @@ def _format_exc_chain(e: BaseException) -> str:
|
||||
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(
|
||||
url: str,
|
||||
*,
|
||||
@@ -501,9 +505,12 @@ async def fetch_openai_account_name(
|
||||
proxy_url = _coerce_proxy_url(proxy_config)
|
||||
if not proxy_url and proxy_config:
|
||||
try:
|
||||
from src.services.proxy_node.resolver import build_proxy_url_async
|
||||
|
||||
proxy_url = await build_proxy_url_async(proxy_config)
|
||||
build_proxy_url_async = _load_optional_attr(
|
||||
"src.services.proxy_node.resolver",
|
||||
"build_proxy_url_async",
|
||||
)
|
||||
if callable(build_proxy_url_async):
|
||||
proxy_url = await build_proxy_url_async(proxy_config)
|
||||
except Exception:
|
||||
proxy_url = None
|
||||
|
||||
@@ -527,9 +534,7 @@ async def fetch_openai_account_name(
|
||||
}
|
||||
for attempt in range(3):
|
||||
if attempt:
|
||||
await asyncio.sleep(
|
||||
[1.0, 2.0][attempt - 1] + random.uniform(0.5, 1.5)
|
||||
)
|
||||
await asyncio.sleep([1.0, 2.0][attempt - 1] + random.uniform(0.5, 1.5))
|
||||
resp = await session.get(_OPENAI_ACCOUNTS_CHECK_URL, headers=headers)
|
||||
if 200 <= resp.status_code < 300:
|
||||
return _extract_openai_account_name(resp.json(), account_id)
|
||||
@@ -638,10 +643,14 @@ async def enrich_auth_config(
|
||||
为支持按需 bootstrap,这里会尝试按 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)
|
||||
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)
|
||||
if enricher:
|
||||
return await enricher(auth_config, token_response, access_token, proxy_config)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -19,6 +19,10 @@ class KiroRequestContext:
|
||||
system_version: str | None = None
|
||||
node_version: str | None = None
|
||||
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(
|
||||
@@ -35,8 +39,44 @@ def get_kiro_request_context() -> KiroRequestContext | None:
|
||||
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__ = [
|
||||
"KiroRequestContext",
|
||||
"get_kiro_request_context",
|
||||
"set_kiro_request_context",
|
||||
"update_kiro_connection_error",
|
||||
"update_kiro_http_status",
|
||||
]
|
||||
|
||||
@@ -12,26 +12,21 @@ from __future__ import annotations
|
||||
|
||||
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.converter import (
|
||||
convert_claude_messages_to_conversation_state,
|
||||
from src.services.provider.adapters.kiro.error_enhancer import (
|
||||
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.models.credentials import KiroAuthConfig
|
||||
from src.services.provider.adapters.kiro.token_manager import generate_machine_id
|
||||
|
||||
|
||||
def _resolve_region(cfg: KiroAuthConfig) -> str:
|
||||
"""解析 API 服务端点的 region(q.{region}.amazonaws.com)。"""
|
||||
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"}
|
||||
from src.services.provider.adapters.kiro.request import (
|
||||
build_kiro_request_context,
|
||||
build_kiro_request_payload,
|
||||
)
|
||||
from src.services.provider.request_context import get_selected_base_url
|
||||
|
||||
|
||||
class KiroEnvelope:
|
||||
@@ -63,34 +58,13 @@ class KiroEnvelope:
|
||||
decrypted_auth_config: dict[str, Any] | None,
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
cfg = KiroAuthConfig.from_dict(decrypted_auth_config or {})
|
||||
|
||||
region = _resolve_region(cfg)
|
||||
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(
|
||||
set_kiro_request_context(build_kiro_request_context(request_body, cfg=cfg))
|
||||
wrapped = build_kiro_request_payload(
|
||||
request_body,
|
||||
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
|
||||
|
||||
def unwrap_response(self, data: Any) -> Any:
|
||||
@@ -100,18 +74,46 @@ class KiroEnvelope:
|
||||
return
|
||||
|
||||
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
|
||||
return
|
||||
def on_http_status(self, *, base_url: str | None, status_code: int) -> None:
|
||||
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
|
||||
return
|
||||
category = classify_kiro_http_status(status_code)
|
||||
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:
|
||||
# Kiro streaming is binary AWS Event Stream and must be rewritten.
|
||||
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()
|
||||
|
||||
|
||||
175
src/services/provider/adapters/kiro/error_enhancer.py
Normal file
175
src/services/provider/adapters/kiro/error_enhancer.py
Normal 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",
|
||||
]
|
||||
@@ -19,6 +19,7 @@ from typing import Any
|
||||
|
||||
from src.core.logger import logger
|
||||
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
|
||||
|
||||
# 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}"
|
||||
except Exception:
|
||||
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(
|
||||
{
|
||||
"type": "error",
|
||||
|
||||
@@ -16,11 +16,13 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from src.services.provider.adapters.kiro.constants import (
|
||||
DEFAULT_REGION,
|
||||
KIRO_GENERATE_ASSISTANT_PATH,
|
||||
)
|
||||
from src.services.provider.adapters.kiro.constants import DEFAULT_REGION
|
||||
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
|
||||
@@ -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
|
||||
# registry from preset_models.py.
|
||||
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")
|
||||
|
||||
@@ -51,15 +54,14 @@ def build_kiro_url(
|
||||
"""
|
||||
_ = is_stream
|
||||
|
||||
base = str(getattr(endpoint, "base_url", "") or "").rstrip("/")
|
||||
|
||||
ctx = get_kiro_request_context()
|
||||
region = (ctx.region if ctx else "") or DEFAULT_REGION
|
||||
if "{region}" in base:
|
||||
base = base.replace("{region}", region)
|
||||
raw_base = str(getattr(endpoint, "base_url", "") or "").rstrip("/")
|
||||
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 = base if base.endswith(path) else f"{base}{path}"
|
||||
url = build_kiro_generate_assistant_url(raw_base, cfg=cfg)
|
||||
|
||||
if effective_query_params:
|
||||
query_string = urlencode(effective_query_params, doseq=True)
|
||||
|
||||
148
src/services/provider/adapters/kiro/request.py
Normal file
148
src/services/provider/adapters/kiro/request.py
Normal 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",
|
||||
]
|
||||
@@ -21,6 +21,7 @@ from src.services.provider.adapters.kiro.models.usage_limits import (
|
||||
calculate_current_usage,
|
||||
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 (
|
||||
generate_machine_id,
|
||||
is_token_expired,
|
||||
@@ -89,7 +90,7 @@ async def fetch_kiro_usage_limits(
|
||||
# 构建 URL(添加 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:
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
@@ -87,6 +87,19 @@ class ProviderEnvelope(Protocol):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user