mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50: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:
|
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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 服务端点的 region(q.{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()
|
||||||
|
|
||||||
|
|||||||
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.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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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_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
|
||||||
|
|
||||||
|
|||||||
@@ -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
7
tests/conftest.py
Normal 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")
|
||||||
150
tests/services/test_kiro_envelope.py
Normal file
150
tests/services/test_kiro_envelope.py
Normal 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
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user