refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构

- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
fawney19
2026-04-03 16:26:16 +08:00
parent 8f26e1a31f
commit 1d9c77522a
868 changed files with 1735 additions and 2433 deletions

View File

@@ -0,0 +1 @@
"""Provider-specific adapters (antigravity, codex, etc.)."""

View File

@@ -0,0 +1 @@
"""Antigravity integration package."""

View File

@@ -0,0 +1,627 @@
"""Antigravity API 客户端(与 Antigravity-Manager 对齐)。"""
from __future__ import annotations
import asyncio
import json
import random
import re
from typing import Any
from src.clients.http_client import HTTPClientPool
from src.core.logger import logger
from src.services.provider.adapters.antigravity.constants import (
DAILY_BASE_URL,
PROD_BASE_URL,
SANDBOX_BASE_URL,
VERSION_FETCH_URL,
get_v1internal_extra_headers,
parse_version_string,
update_user_agent_version,
)
from src.services.provider.adapters.antigravity.rust_http import (
execute_antigravity_rust_http_request,
)
from src.services.provider.adapters.antigravity.url_availability import url_availability
# loadCodeAssist 请求体 metadata
_CODE_ASSIST_METADATA = {
"ideType": "ANTIGRAVITY",
}
# Duration 解析正则(与 AM 的 retry.rs 对齐)
_DURATION_RE = re.compile(r"([\d.]+)\s*(ms|s|m|h)")
# ---------------------------------------------------------------------------
# Retry-After / Duration 解析工具(对齐 AM upstream/retry.rs
# ---------------------------------------------------------------------------
def _parse_duration_ms(duration_str: str) -> int | None:
"""解析 Duration 字符串 (e.g. '1.5s', '200ms', '1h16m0.667s'),返回毫秒。"""
total_ms = 0.0
matched = False
for m in _DURATION_RE.finditer(duration_str):
matched = True
value = float(m.group(1))
unit = m.group(2)
if unit == "ms":
total_ms += value
elif unit == "s":
total_ms += value * 1000
elif unit == "m":
total_ms += value * 60_000
elif unit == "h":
total_ms += value * 3_600_000
return round(total_ms) if matched else None
def parse_retry_delay(error_body: str | bytes | dict[str, Any]) -> float | None:
"""从 429 错误响应体中提取 retry delay
支持两种格式(与 AM 对齐):
1. error.details[].@type="...RetryInfo" → retryDelay
2. error.details[].metadata.quotaResetDelay
"""
try:
data: dict[str, Any]
if isinstance(error_body, (str, bytes)):
data = json.loads(error_body)
elif isinstance(error_body, dict):
data = error_body
else:
return None
details = data.get("error", {}).get("details", [])
if not isinstance(details, list):
return None
# 方式 1: RetryInfo.retryDelay
for detail in details:
if not isinstance(detail, dict):
continue
type_str = detail.get("@type", "")
if isinstance(type_str, str) and "RetryInfo" in type_str:
retry_delay = detail.get("retryDelay")
if isinstance(retry_delay, str):
ms = _parse_duration_ms(retry_delay)
if ms is not None:
return min((ms + 200) / 1000.0, 30.0)
# 方式 2: metadata.quotaResetDelay
for detail in details:
if not isinstance(detail, dict):
continue
metadata = detail.get("metadata")
if not isinstance(metadata, dict):
continue
quota_delay = metadata.get("quotaResetDelay")
if isinstance(quota_delay, str):
ms = _parse_duration_ms(quota_delay)
if ms is not None:
return min((ms + 200) / 1000.0, 30.0)
except Exception:
pass
return None
# ---------------------------------------------------------------------------
# Fallback 判断(对齐 AM upstream/client.rs: should_try_next_endpoint
# ---------------------------------------------------------------------------
def _should_fallback_status(status_code: int) -> bool:
"""判断是否应该 fallback 到下一个端点。
与 AM 对齐429 + 408(超时) + 404 + 所有 5xx。
4xx 客户端错误400/401/403 等)不 fallback——换 URL 也不会成功。
"""
if status_code == 429:
return True
if status_code in (404, 408):
return True
if 500 <= status_code < 600: # noqa: PLR2004
return True
return False
# ---------------------------------------------------------------------------
# 账户封禁异常(对齐 AM: is_forbidden 标志)
# ---------------------------------------------------------------------------
class AntigravityAccountForbiddenException(Exception):
"""Antigravity 账户被封禁/禁止访问异常。
当 API 返回 403 Forbidden 时抛出,表示账户权限被撤销。
"""
def __init__(
self,
message: str = "账户访问被禁止",
status_code: int = 403,
reason: str | None = None,
):
super().__init__(message)
self.message = message
self.status_code = status_code
self.reason = reason
def _extract_forbidden_reason(response_text: str) -> str | None:
"""从 403 响应体中提取封禁原因。
尝试解析 JSON 响应中的 error.message 字段。
"""
if not response_text:
return None
try:
data = json.loads(response_text)
if isinstance(data, dict):
error = data.get("error")
if isinstance(error, dict):
message = error.get("message")
if isinstance(message, str) and message.strip():
return message.strip()
# 直接在顶层查找 message
message = data.get("message")
if isinstance(message, str) and message.strip():
return message.strip()
except Exception:
pass
# 如果无法解析,返回原始文本的前 100 个字符
if len(response_text) > 100:
return response_text[:100] + "..."
return response_text if response_text.strip() else None
# ---------------------------------------------------------------------------
# 从 loadCodeAssist 响应中提取信息
# ---------------------------------------------------------------------------
def extract_tier_id(data: dict[str, Any]) -> str:
"""从 loadCodeAssist 响应中提取 tier ID。
优先选 allowedTiers 中 isDefault=true 的fallback 到第一个,最终 fallback 到 "LEGACY"
"""
allowed_tiers = data.get("allowedTiers")
if not isinstance(allowed_tiers, list):
return "LEGACY"
# 第一轮:找 isDefault
for tier in allowed_tiers:
if isinstance(tier, dict) and tier.get("isDefault") is True:
tier_id = tier.get("id", "")
if isinstance(tier_id, str) and tier_id.strip():
return tier_id.strip()
# 第二轮:取第一个有 id 的
for tier in allowed_tiers:
if isinstance(tier, dict):
tier_id = tier.get("id", "")
if isinstance(tier_id, str) and tier_id.strip():
return tier_id.strip()
return "LEGACY"
def extract_project_id(data: dict[str, Any]) -> str:
"""从响应中提取 project_id兼容 string 和 {"id": "..."} 两种格式。"""
raw = data.get("cloudaicompanionProject")
if isinstance(raw, str) and raw.strip():
return raw.strip()
if isinstance(raw, dict):
pid = raw.get("id", "")
if isinstance(pid, str) and pid.strip():
return pid.strip()
return ""
# ---------------------------------------------------------------------------
# 核心 API 客户端函数
# ---------------------------------------------------------------------------
async def load_code_assist(
access_token: str,
proxy_config: dict[str, Any] | None = None,
*,
timeout_seconds: float = 10.0,
) -> dict[str, Any]:
"""调用 /v1internal:loadCodeAssist 获取账户信息。
对齐 AM project_resolver.rsSandbox 优先,避免 Prod 429。
注意:
- email 需通过 Google userinfo API 获取(由 enrich_auth_config 复用已有逻辑)
- 这里仅负责 project_id / tier 等信息
"""
if not access_token:
raise ValueError("missing access_token")
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
**get_v1internal_extra_headers(),
}
body = {"metadata": _CODE_ASSIST_METADATA}
# Sandbox 优先(与 AM 对齐:避免 Prod 429
urls = url_availability.get_ordered_urls(prefer_daily=True)
if not urls:
urls = [SANDBOX_BASE_URL, DAILY_BASE_URL, PROD_BASE_URL]
last_exc: Exception | None = None
for base_url in urls:
try:
url = f"{base_url}/v1internal:loadCodeAssist"
resp = await execute_antigravity_rust_http_request(
method="POST",
url=url,
headers=headers,
body=body,
proxy_config=proxy_config,
request_id=f"antigravity:load-code-assist:{base_url}",
provider_api_format="antigravity:load_code_assist",
timeout_seconds=timeout_seconds,
content_type="application/json",
)
if resp is None:
client = await HTTPClientPool.get_proxy_client(proxy_config)
resp = await client.post(
url,
json=body,
headers=headers,
timeout=timeout_seconds,
)
if 200 <= resp.status_code < 300:
url_availability.mark_success(base_url)
data = resp.json()
return data if isinstance(data, dict) else {}
# 可 fallback 的错误:标记不可用,尝试下一个 URL
if _should_fallback_status(resp.status_code):
url_availability.mark_unavailable(base_url)
# 429尝试解析 Retry-After 并等待
if resp.status_code == 429:
delay = parse_retry_delay(resp.text)
if delay and delay > 0:
logger.debug(
"[antigravity] loadCodeAssist 429, waiting {:.1f}s before fallback",
delay,
)
await asyncio.sleep(min(delay, 5.0))
last_exc = RuntimeError(
f"loadCodeAssist failed: status={resp.status_code} base_url={base_url}"
)
continue
# 不可 fallback 的 4xx400/401/403 等):直接抛出,换 URL 也不会成功
raise RuntimeError(
f"loadCodeAssist failed: status={resp.status_code} base_url={base_url} "
f"body={resp.text[:200] if resp.text else ''}"
)
except RuntimeError:
raise
except Exception as e:
url_availability.mark_unavailable(base_url)
last_exc = e
continue
raise last_exc or RuntimeError("loadCodeAssist failed: all endpoints exhausted")
async def onboard_user(
access_token: str,
tier_id: str = "LEGACY",
proxy_config: dict[str, Any] | None = None,
*,
max_attempts: int = 5,
poll_interval: float = 2.0,
timeout_seconds: float = 30.0,
) -> str:
"""调用 /v1internal:onboardUser 激活账号并获取 project_id。
当 loadCodeAssist 返回 allowedTiers 但没有 cloudaicompanionProject 时,
需要先通过 onboardUser 选择 tier 来分配 project。
改进(对齐 AM
- 使用 url_availability 做多 URL fallback
- 轮询期间的临时网络错误会 continue 而非直接终止
Args:
access_token: OAuth access token
tier_id: 要选择的 tier ID从 allowedTiers 中提取)
proxy_config: 代理配置
max_attempts: 最大轮询次数onboardUser 是异步操作)
poll_interval: 轮询间隔(秒)
timeout_seconds: 单次请求超时
Returns:
project_id
Raises:
RuntimeError: 激活失败
"""
if not access_token:
raise ValueError("missing access_token")
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
**get_v1internal_extra_headers(),
}
body = {
"tierId": tier_id,
"metadata": _CODE_ASSIST_METADATA,
}
# 使用 url_availability 选择端点(与其他函数一致)
urls = url_availability.get_ordered_urls(prefer_daily=True)
if not urls:
urls = [SANDBOX_BASE_URL, DAILY_BASE_URL, PROD_BASE_URL]
base_url = urls[0]
logger.info("[antigravity] onboardUser: 开始激活账号, tier={}, endpoint={}", tier_id, base_url)
for attempt in range(1, max_attempts + 1):
try:
url = f"{base_url}/v1internal:onboardUser"
resp = await execute_antigravity_rust_http_request(
method="POST",
url=url,
headers=headers,
body=body,
proxy_config=proxy_config,
request_id=f"antigravity:onboard-user:{tier_id}:{attempt}",
provider_api_format="antigravity:onboard_user",
timeout_seconds=timeout_seconds,
content_type="application/json",
)
if resp is None:
client = await HTTPClientPool.get_proxy_client(proxy_config)
resp = await client.post(
url,
json=body,
headers=headers,
timeout=timeout_seconds,
)
if resp.status_code < 200 or resp.status_code >= 300:
# 标记可用性
if _should_fallback_status(resp.status_code):
url_availability.mark_unavailable(base_url)
raise RuntimeError(
f"onboardUser failed: status={resp.status_code}, "
f"body={resp.text[:200] if resp.text else ''}"
)
url_availability.mark_success(base_url)
data = resp.json()
if not isinstance(data, dict):
raise RuntimeError(f"onboardUser: unexpected response type: {type(data)}")
done = data.get("done")
if done is True:
# 从 response.cloudaicompanionProject 提取 project_id
response_data = data.get("response")
if isinstance(response_data, dict):
project_id = extract_project_id(response_data)
if project_id:
logger.info(
"[antigravity] onboardUser: 激活成功, project_id={}",
project_id[:8] + "...",
)
return project_id
# done=true 但 project_id 为空常见cloudaicompanionProject: {}
# 返回空串,由调用方 fallback 到随机 project_id
logger.debug("[antigravity] onboardUser: done=true 但 project_id 为空")
return ""
# done != true继续轮询
logger.debug(
"[antigravity] onboardUser: 轮询 {}/{}, 等待完成...",
attempt,
max_attempts,
)
if attempt < max_attempts:
await asyncio.sleep(poll_interval)
except RuntimeError:
raise
except Exception as e:
# 临时网络错误:记录并继续轮询(而非直接终止)
logger.warning(
"[antigravity] onboardUser: 轮询 {}/{} 网络错误: {}, 继续重试...",
attempt,
max_attempts,
e,
)
if attempt < max_attempts:
await asyncio.sleep(poll_interval)
continue
raise RuntimeError(
f"onboardUser request failed after {max_attempts} attempts: {e}"
) from e
raise RuntimeError(f"onboardUser: 超时,已轮询 {max_attempts} 次仍未完成")
async def fetch_available_models(
access_token: str,
*,
project_id: str,
proxy_config: dict[str, Any] | None = None,
timeout_seconds: float = 10.0,
) -> dict[str, Any]:
"""调用 /v1internal:fetchAvailableModels 获取可用模型(包含配额信息)。
Antigravity 的此接口会返回类似:
{"models": {"claude-sonnet-4": {"displayName": "...", "quotaInfo": {...}}, ...}}
对齐 AMSandbox 优先 + 正确的 fallback 逻辑。
"""
if not access_token:
raise ValueError("missing access_token")
if not project_id:
raise ValueError("missing project_id")
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json",
**get_v1internal_extra_headers(),
}
body = {"project": project_id}
# Sandbox 优先
urls = url_availability.get_ordered_urls(prefer_daily=True)
if not urls:
urls = [SANDBOX_BASE_URL, DAILY_BASE_URL, PROD_BASE_URL]
last_exc: Exception | None = None
for base_url in urls:
try:
url = f"{base_url}/v1internal:fetchAvailableModels"
resp = await execute_antigravity_rust_http_request(
method="POST",
url=url,
headers=headers,
body=body,
proxy_config=proxy_config,
request_id=f"antigravity:fetch-available-models:{base_url}",
provider_api_format="antigravity:fetch_available_models",
timeout_seconds=timeout_seconds,
content_type="application/json",
)
if resp is None:
client = await HTTPClientPool.get_proxy_client(proxy_config)
resp = await client.post(
url,
json=body,
headers=headers,
timeout=timeout_seconds,
)
if 200 <= resp.status_code < 300:
url_availability.mark_success(base_url)
data = resp.json()
return data if isinstance(data, dict) else {}
# 可 fallback 的错误
if _should_fallback_status(resp.status_code):
url_availability.mark_unavailable(base_url)
# 429尝试等待
if resp.status_code == 429:
delay = parse_retry_delay(resp.text)
if delay and delay > 0:
logger.debug(
"[antigravity] fetchAvailableModels 429, waiting {:.1f}s",
delay,
)
await asyncio.sleep(min(delay, 5.0))
last_exc = RuntimeError(
f"fetchAvailableModels failed: status={resp.status_code} base_url={base_url}"
)
continue
# 403 Forbidden账户权限被禁止对齐 AM is_forbidden 标志)
if resp.status_code == 403:
reason = _extract_forbidden_reason(resp.text)
logger.warning(
"[antigravity] fetchAvailableModels 403 Forbidden: {}",
reason or "unknown",
)
raise AntigravityAccountForbiddenException(
message="账户访问被禁止",
status_code=403,
reason=reason,
)
# 不可 fallback 的 4xx直接抛出
raise RuntimeError(
f"fetchAvailableModels failed: status={resp.status_code} base_url={base_url} "
f"body={resp.text[:200] if resp.text else ''}"
)
except (RuntimeError, AntigravityAccountForbiddenException):
raise
except Exception as e:
url_availability.mark_unavailable(base_url)
last_exc = e
continue
raise last_exc or RuntimeError("fetchAvailableModels failed: all endpoints exhausted")
# ---------------------------------------------------------------------------
# User-Agent 版本号动态更新
# ---------------------------------------------------------------------------
async def refresh_user_agent(
proxy_config: dict[str, Any] | None = None,
*,
timeout_seconds: float = 5.0,
) -> str | None:
"""从远程 API 获取最新 Antigravity 版本号并更新 User-Agent。
对齐 AM constants.rs
1. 尝试 VERSION_FETCH_URL
2. Fallback 到 changelog 页面
3. 最终 fallback 到 _FALLBACK_VERSION
Returns:
更新后的版本号,或 None如果获取失败但 fallback 到默认版本)。
"""
try:
client = await HTTPClientPool.get_proxy_client(proxy_config)
resp = await client.get(VERSION_FETCH_URL, timeout=timeout_seconds)
if 200 <= resp.status_code < 300:
version = parse_version_string(resp.text)
if version:
update_user_agent_version(version)
logger.info("[antigravity] User-Agent 版本已更新: {}", version)
return version
except Exception as e:
logger.debug("[antigravity] 获取远程版本失败: {}", e)
return None
# ---------------------------------------------------------------------------
# Fallback Project ID 生成
# ---------------------------------------------------------------------------
_ADJECTIVES = ("useful", "bright", "swift", "calm", "bold")
_NOUNS = ("fuze", "wave", "spark", "flow", "core")
def generate_fallback_project_id() -> str:
"""生成随机 project_id与 AM project_resolver.rs 的 generateProjectID 一致)。
格式: {adjective}-{noun}-{5位随机字符(base36)}
Antigravity API 不严格校验 project 字段,当所有正常获取途径都失败时用作 fallback。
"""
adj = random.choice(_ADJECTIVES) # noqa: S311
noun = random.choice(_NOUNS) # noqa: S311
chars = "abcdefghijklmnopqrstuvwxyz0123456789"
rand_part = "".join(random.choice(chars) for _ in range(5)) # noqa: S311
return f"{adj}-{noun}-{rand_part}"
__all__ = [
"AntigravityAccountForbiddenException",
"extract_project_id",
"extract_tier_id",
"fetch_available_models",
"generate_fallback_project_id",
"load_code_assist",
"onboard_user",
"parse_retry_delay",
"refresh_user_agent",
]

View File

@@ -0,0 +1,279 @@
"""Antigravity 全局常量定义。
注意:这里的 PROVIDER_TYPE 指的是 Provider.provider_type用于路由与特判
不是 endpoint signaturefamily:kind
"""
from __future__ import annotations
import platform
import re
import threading
import uuid
# ============== API 端点 ==============
# 唯一定义在 core 层,此处 re-export 保持向后兼容
from src.core.provider_templates.fixed_providers import ANTIGRAVITY_PROD_URL as PROD_BASE_URL
DAILY_BASE_URL = "https://daily-cloudcode-pa.googleapis.com"
SANDBOX_BASE_URL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
# ============== User-Agent ==============
VERSION_FETCH_URL = "https://antigravity-auto-updater-974169037036.us-central1.run.app"
_FALLBACK_VERSION = "1.18.4"
_FALLBACK_CHROME = "132.0.6834.160"
_FALLBACK_ELECTRON = "39.2.3"
_VERSION_RE = re.compile(r"\d+\.\d+\.\d+")
def _detect_platform_info() -> str:
"""检测当前运行平台,格式对齐 AM constants.rs 的 Electron UA。"""
os_name = platform.system().lower()
if os_name == "darwin":
return "Macintosh; Intel Mac OS X 10_15_7"
elif os_name == "windows":
return "Windows NT 10.0; Win64; x64"
else:
return "X11; Linux x86_64"
_PLATFORM_INFO = _detect_platform_info()
def _build_antigravity_http_user_agent(
*,
platform_token: str,
version: str,
chrome_version: str,
electron_version: str,
) -> str:
return (
f"Mozilla/5.0 ({platform_token}) AppleWebKit/537.36 (KHTML, like Gecko) "
f"Antigravity/{version} Chrome/{chrome_version} "
f"Electron/{electron_version} Safari/537.36"
)
# HTTP Header User-Agent对齐 AM constants.rs: 完整 Electron 浏览器格式)
HTTP_USER_AGENT = _build_antigravity_http_user_agent(
platform_token=_PLATFORM_INFO,
version=_FALLBACK_VERSION,
chrome_version=_FALLBACK_CHROME,
electron_version=_FALLBACK_ELECTRON,
)
# V1InternalRequest.userAgent 字段(固定值)
REQUEST_USER_AGENT = "antigravity"
# --- 动态 User-Agent 支持 ---
_ua_lock = threading.Lock()
_ua_version: str = _FALLBACK_VERSION
def get_http_user_agent() -> str:
"""返回当前 HTTP User-Agent 字符串(对齐 AM Electron UA 格式)。"""
with _ua_lock:
return _build_antigravity_http_user_agent(
platform_token=_PLATFORM_INFO,
version=_ua_version,
chrome_version=_FALLBACK_CHROME,
electron_version=_FALLBACK_ELECTRON,
)
def update_user_agent_version(version: str) -> None:
"""更新 User-Agent 中的版本号(由 refresh_user_agent 调用)。"""
global HTTP_USER_AGENT, _ua_version # noqa: PLW0603
version = str(version or "").strip()
if not version:
return
with _ua_lock:
_ua_version = version
HTTP_USER_AGENT = _build_antigravity_http_user_agent(
platform_token=_PLATFORM_INFO,
version=_ua_version,
chrome_version=_FALLBACK_CHROME,
electron_version=_FALLBACK_ELECTRON,
)
def parse_version_string(text: str) -> str | None:
"""从任意文本中提取 X.Y.Z 格式的版本号。"""
m = _VERSION_RE.search(text)
return m.group(0) if m else None
# ============== URL 可用性 ==============
URL_UNAVAILABLE_TTL_SECONDS = 300 # 5 分钟
# ============== AM Client Identity Headers ==============
# 对齐 AM upstream/client.rs: 伪装为官方 Antigravity 客户端
# 缺少这些 header 会导致新模型(如 gemini-3.1-pro-preview返回 404
_SESSION_ID = uuid.uuid4().hex # 每次进程启动生成一个固定 session ID
def get_v1internal_extra_headers() -> dict[str, str]:
"""构建 v1internal 请求需要的额外 header对齐 AM upstream/client.rs"""
with _ua_lock:
version = _ua_version
return {
"User-Agent": get_http_user_agent(),
"x-client-name": "antigravity",
"x-client-version": version,
"x-vscode-sessionid": _SESSION_ID,
"x-goog-api-client": "gl-node/18.18.2 fire/0.8.6 grpc/1.10.x",
}
# ============== Thinking Signature ==============
# 统一从 core 层导入,避免多处定义
from src.core.api_format.conversion.constants import DUMMY_THOUGHT_SIGNATURE # noqa: E402
from src.core.api_format.conversion.thinking_cache import MIN_SIGNATURE_LENGTH # noqa: E402, F401
# ============== Thinking Budget ==============
THINKING_BUDGET_AUTO_CAP = 24576
THINKING_BUDGET_DEFAULT_INJECT = 24576 # 对齐 AM wrapper.rs (was 16000)
# 给输出留的空间(对齐 Antigravity-Manager普通模型 8192图像模型 2048
OUTPUT_OVERHEAD = 8192
OUTPUT_OVERHEAD_IMAGE = 2048
# 模型最大输出限制(防止超限)
MODEL_MAX_OUTPUT_LIMIT = 65536
# 包含这些关键字的模型会自动注入 thinkingConfig如果缺失
THINKING_MODELS_AUTO_INJECT_KEYWORDS = (
"thinking",
"gemini-2.0-pro",
"gemini-3-pro",
"gemini-3.1-pro",
)
# ============== Google Search (Grounding) ==============
# 对齐 AM common_utils.rs: 仅 gemini-2.5-flash 支持 googleSearch tool
WEB_SEARCH_MODEL = "gemini-2.5-flash"
# 联网工具检测关键字(对齐 AM detects_networking_tool
NETWORKING_TOOL_KEYWORDS = frozenset(
{
"web_search",
"google_search",
"web_search_20250305",
"google_search_retrieval",
}
)
# ============== Image Generation ==============
# 上游图像生成模型的固定名称
IMAGE_GEN_UPSTREAM_MODEL = "gemini-3-pro-image"
# 模型后缀 → 宽高比映射
IMAGE_ASPECT_RATIO_SUFFIXES: dict[str, str] = {
"-21x9": "21:9",
"-21-9": "21:9",
"-16x9": "16:9",
"-16-9": "16:9",
"-9x16": "9:16",
"-9-16": "9:16",
"-4x3": "4:3",
"-4-3": "4:3",
"-3x4": "3:4",
"-3-4": "3:4",
"-3x2": "3:2",
"-3-2": "3:2",
"-2x3": "2:3",
"-2-3": "2:3",
"-5x4": "5:4",
"-5-4": "5:4",
"-4x5": "4:5",
"-4-5": "4:5",
"-1x1": "1:1",
"-1-1": "1:1",
}
# 标准宽高比字符串(用于直接匹配 size 参数)
STANDARD_ASPECT_RATIOS = frozenset(
{
"21:9",
"16:9",
"9:16",
"4:3",
"3:4",
"3:2",
"2:3",
"5:4",
"4:5",
"1:1",
}
)
# 宽高比容差匹配表:(ratio, label)
ASPECT_RATIO_TABLE: tuple[tuple[float, str], ...] = (
(21.0 / 9.0, "21:9"),
(16.0 / 9.0, "16:9"),
(4.0 / 3.0, "4:3"),
(3.0 / 4.0, "3:4"),
(9.0 / 16.0, "9:16"),
(3.0 / 2.0, "3:2"),
(2.0 / 3.0, "2:3"),
(5.0 / 4.0, "5:4"),
(4.0 / 5.0, "4:5"),
(1.0, "1:1"),
)
# ============== Retry ==============
RETRY_429_BASE_SECONDS = 5.0
RETRY_503_BASE_SECONDS = 10.0
RETRY_503_MAX_SECONDS = 60.0
RETRY_500_BASE_SECONDS = 3.0
# ============== v1internal 路径 ==============
V1INTERNAL_PATH_TEMPLATE = "/v1internal:{action}"
# ============== Signature 错误关键字(用于 400 错误检测) ==============
SIGNATURE_ERROR_KEYWORDS = (
"Invalid `signature`",
"thinking.signature",
"thinking.thinking",
"Corrupted thought signature",
)
# ============== Antigravity System Instruction ==============
ANTIGRAVITY_SYSTEM_INSTRUCTION = (
"You are Antigravity, a powerful agentic AI coding assistant designed by the "
"Google Deepmind team working on Advanced Agentic Coding.\n"
"You are pair programming with a USER to solve their coding task. The task may "
"require creating a new codebase, modifying or debugging an existing codebase, "
"or simply answering a question.\n"
"**Absolute paths only**\n"
"**Proactiveness**"
)
__all__ = [
"ANTIGRAVITY_SYSTEM_INSTRUCTION",
"ASPECT_RATIO_TABLE",
"DAILY_BASE_URL",
"DUMMY_THOUGHT_SIGNATURE",
"HTTP_USER_AGENT",
"IMAGE_ASPECT_RATIO_SUFFIXES",
"IMAGE_GEN_UPSTREAM_MODEL",
"MIN_SIGNATURE_LENGTH",
"MODEL_MAX_OUTPUT_LIMIT",
"NETWORKING_TOOL_KEYWORDS",
"OUTPUT_OVERHEAD",
"OUTPUT_OVERHEAD_IMAGE",
"PROD_BASE_URL",
"REQUEST_USER_AGENT",
"RETRY_429_BASE_SECONDS",
"RETRY_500_BASE_SECONDS",
"RETRY_503_BASE_SECONDS",
"RETRY_503_MAX_SECONDS",
"SANDBOX_BASE_URL",
"SIGNATURE_ERROR_KEYWORDS",
"STANDARD_ASPECT_RATIOS",
"THINKING_BUDGET_AUTO_CAP",
"THINKING_BUDGET_DEFAULT_INJECT",
"THINKING_MODELS_AUTO_INJECT_KEYWORDS",
"URL_UNAVAILABLE_TTL_SECONDS",
"V1INTERNAL_PATH_TEMPLATE",
"VERSION_FETCH_URL",
"WEB_SEARCH_MODEL",
"get_http_user_agent",
"get_v1internal_extra_headers",
"parse_version_string",
"update_user_agent_version",
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,388 @@
"""Antigravity provider plugin — 统一注册入口。
将 Antigravity 对各通用 registry / capability registry 的注册集中在一个文件中:
- Envelope (v1internal 信封)
- Transport Hook (URL 构建)
- Auth Enricher (OAuth enrichment)
- Model Fetcher (模型获取)
- Provider Format Capability跨格式变体
新增 provider 时参照此文件创建对应的 plugin.py 即可。
"""
from __future__ import annotations
import time
from typing import Any
from urllib.parse import urlencode
from src.core.logger import logger
from src.services.provider.adapters.antigravity.constants import V1INTERNAL_PATH_TEMPLATE
from src.services.provider.adapters.antigravity.url_availability import url_availability
from src.services.provider.request_context import set_selected_base_url
# ---------------------------------------------------------------------------
# Transport Hook
# ---------------------------------------------------------------------------
def build_antigravity_url(
endpoint: Any,
*,
is_stream: bool,
effective_query_params: dict[str, Any],
**_kwargs: Any,
) -> str:
"""构建 Antigravity v1internal URL。
使用 url_availability 选择最优端点,构建 v1internal:generateContent 或
v1internal:streamGenerateContent URL。
"""
ordered_urls = url_availability.get_ordered_urls(prefer_daily=True)
base_url = ordered_urls[0] if ordered_urls else endpoint.base_url
# 存入 contextvars供后续 Handler 层 envelope 获取)
set_selected_base_url(str(base_url) if base_url is not None else None)
action = "streamGenerateContent" if is_stream else "generateContent"
path = V1INTERNAL_PATH_TEMPLATE.format(action=action)
query_params = dict(effective_query_params)
# v1internal 流式请求同样支持 ?alt=sse
if is_stream:
query_params.setdefault("alt", "sse")
# 移除 v1internal 不支持的查询参数
query_params.pop("beta", None)
url = f"{str(base_url).rstrip('/')}{path}"
if query_params:
query_string = urlencode(query_params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
return url
# ---------------------------------------------------------------------------
# Auth Enricher
# ---------------------------------------------------------------------------
async def enrich_antigravity(
auth_config: dict[str, Any],
token_response: dict[str, Any],
access_token: str,
proxy_config: dict[str, Any] | None,
) -> dict[str, Any]:
"""Antigravity auth_config enrichment.
1. 通过 Google userinfo API 获取 email
2. 通过 loadCodeAssist 获取 project_id / tier
3. 未激活账号尝试 onboardUser
4. fallback 到随机 project_id
"""
from src.core.provider_oauth_utils import fetch_google_email
from src.services.provider.adapters.antigravity.client import (
extract_project_id,
extract_tier_id,
generate_fallback_project_id,
load_code_assist,
onboard_user,
)
# Email仅在缺失时获取
if not auth_config.get("email"):
email = await fetch_google_email(
access_token,
proxy_config=proxy_config,
timeout_seconds=10.0,
)
if email:
auth_config["email"] = email
# Project ID + Tier需要 loadCodeAssist 时一起获取)
need_project = not auth_config.get("project_id")
# Tier 每次 enrich 都重新获取(确保归一化为 Free/Pro/Ultra
need_tier = True
if need_project or need_tier:
project_id = auth_config.get("project_id", "")
try:
code_assist = await load_code_assist(access_token, proxy_config=proxy_config)
# 提取 tier 信息(对齐 sub2api优先 paidTierfallback currentTier
tier_str = _extract_tier_from_code_assist(code_assist)
if tier_str:
auth_config["tier"] = tier_str
logger.info("[enrich] Antigravity tier: {}", tier_str)
if need_project:
project_id = extract_project_id(code_assist)
# 未激活:尝试 onboardUser
if not project_id and code_assist.get("allowedTiers"):
tier_id = extract_tier_id(code_assist)
logger.info("[enrich] Antigravity onboardUser tier={}", tier_id)
project_id = await onboard_user(
access_token,
tier_id=tier_id,
proxy_config=proxy_config,
)
except Exception as e:
logger.warning("[enrich] Antigravity loadCodeAssist/onboardUser 失败: {}", e)
if need_project:
if project_id:
auth_config["project_id"] = project_id
logger.info("[enrich] Antigravity project_id: {}", project_id[:8] + "...")
else:
fallback = generate_fallback_project_id()
auth_config["project_id"] = fallback
logger.info("[enrich] Antigravity 随机 project_id fallback: {}", fallback)
return auth_config
def _extract_tier_from_code_assist(code_assist: dict[str, Any]) -> str:
"""从 loadCodeAssist 响应中提取用户层级,返回 Free/Pro/Ultra。
对齐 sub2api/CLIProxyAPI优先 paidTierfallback currentTier。
兼容 tier 为字符串或 {"id": "...", "tierType": "..."} 两种格式。
"""
# 优先 paidTier付费订阅级别
paid_tier = code_assist.get("paidTier")
tier = _normalize_tier(_extract_tier_raw(paid_tier))
if tier:
return tier
# fallback currentTier
current_tier = code_assist.get("currentTier")
tier = _normalize_tier(_extract_tier_raw(current_tier))
if tier:
return tier
return "Free"
def _extract_tier_raw(tier_obj: Any) -> str:
"""从 tier 对象中提取原始标识,兼容字符串和字典两种格式。"""
if isinstance(tier_obj, str) and tier_obj.strip():
return tier_obj.strip()
if isinstance(tier_obj, dict):
for key in ("id", "tierType"):
val = tier_obj.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
return ""
def _normalize_tier(raw: str) -> str:
"""将上游 tier 标识归一化为 Free/Pro/Ultra。
上游格式示例:
- id: "free-tier", "g1-pro-tier", "g1-ultra-tier"
- tierType: "FREE", "PAID"
"""
if not raw:
return ""
lower = raw.lower()
if "ultra" in lower:
return "Ultra"
if "pro" in lower or "paid" in lower:
return "Pro"
if "free" in lower or "legacy" in lower:
return "Free"
return raw
# ---------------------------------------------------------------------------
# Model Fetcher
# ---------------------------------------------------------------------------
# Antigravity 内部测试/调试模型,不应暴露给用户
BLOCKED_MODELS: frozenset[str] = frozenset({"chat_23310", "chat_20706"})
async def fetch_models_antigravity(
ctx: Any,
timeout_seconds: float,
) -> tuple[list[dict], list[str], bool, dict[str, Any] | None]:
"""Antigravity 模型获取策略。
调用 v1internal:fetchAvailableModels 获取可用模型列表,
解析配额信息,过滤黑名单模型。
"""
from src.services.provider.adapters.antigravity.client import (
AntigravityAccountForbiddenException,
fetch_available_models,
)
auth_config = ctx.auth_config or {}
project_id = auth_config.get("project_id")
if not isinstance(project_id, str) or not project_id.strip():
return [], ["antigravity: missing auth_config.project_id (please re-auth)"], False, None
try:
data = await fetch_available_models(
ctx.api_key_value,
project_id=project_id.strip(),
proxy_config=ctx.proxy_config,
timeout_seconds=timeout_seconds,
)
except AntigravityAccountForbiddenException:
# 重新抛出由上层keys.py处理自动停用逻辑
raise
except Exception as e:
return [], [f"antigravity: fetchAvailableModels error: {e}"], False, None
raw_models = data.get("models")
if not isinstance(raw_models, dict):
return [], ["antigravity: invalid response (missing models)"], False, None
models: list[dict] = []
quota_by_model: dict[str, dict[str, Any]] = {}
for model_id, model_data in raw_models.items():
if not isinstance(model_id, str) or not model_id.strip():
continue
if model_id.strip() in BLOCKED_MODELS:
continue
if not isinstance(model_data, dict):
model_data = {}
display_name = model_data.get("displayName")
if not isinstance(display_name, str) or not display_name:
display_name = model_id
models.append(
{
"id": model_id,
"owned_by": "antigravity",
"display_name": display_name,
"api_format": "gemini:chat",
}
)
quota_info = model_data.get("quotaInfo")
if not isinstance(quota_info, dict):
# 没有 quotaInfo 视为配额耗尽
quota_by_model[model_id] = {
"remaining_fraction": 0.0,
"used_percent": 100.0,
}
continue
remaining = quota_info.get("remainingFraction")
reset_time = quota_info.get("resetTime")
remaining_fraction: float | None = None
try:
if remaining is not None:
remaining_fraction = float(remaining)
except Exception:
remaining_fraction = None
if remaining_fraction is None:
# remainingFraction 缺失视为配额耗尽
payload: dict[str, Any] = {
"remaining_fraction": 0.0,
"used_percent": 100.0,
}
if isinstance(reset_time, str) and reset_time.strip():
payload["reset_time"] = reset_time.strip()
quota_by_model[model_id] = payload
continue
used_percent = (1.0 - remaining_fraction) * 100.0
if used_percent < 0:
used_percent = 0.0
if used_percent > 100: # noqa: PLR2004
used_percent = 100.0
payload: dict[str, Any] = {
"remaining_fraction": remaining_fraction,
"used_percent": used_percent,
}
if isinstance(reset_time, str) and reset_time.strip():
payload["reset_time"] = reset_time.strip()
quota_by_model[model_id] = payload
upstream_metadata: dict[str, Any] | None = None
if quota_by_model:
upstream_metadata = {
"antigravity": {
"updated_at": int(time.time()),
"quota_by_model": quota_by_model,
}
}
return models, [], True, upstream_metadata
# ---------------------------------------------------------------------------
# Export builder
# ---------------------------------------------------------------------------
_AG_SKIP_KEYS = frozenset(
{
"access_token",
"expires_at",
"updated_at",
"token_type",
"scope",
}
)
def antigravity_export_builder(
auth_config: dict[str, Any],
upstream_metadata: dict[str, Any] | None,
) -> dict[str, Any]:
"""Antigravity 导出:保留 refresh_token / email / project_id / tier。"""
return {
k: v for k, v in auth_config.items() if k not in _AG_SKIP_KEYS and v is not None and v != ""
}
# ---------------------------------------------------------------------------
# Unified Registration
# ---------------------------------------------------------------------------
def register_all() -> None:
"""一次性注册 Antigravity 的所有 hooks 到各通用 registry。"""
from src.core.api_format.capabilities import register_provider_behavior_variant
from src.core.provider_oauth_utils import register_auth_enricher
from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
from src.services.provider.adapters.antigravity.envelope import antigravity_v1internal_envelope
from src.services.provider.envelope import register_envelope
from src.services.provider.export import register_export_builder
from src.services.provider.transport import register_transport_hook
# Envelope
register_envelope("antigravity", "gemini:chat", antigravity_v1internal_envelope)
# Backward compat: allow existing endpoints that still use the old signature.
register_envelope("antigravity", "gemini:cli", antigravity_v1internal_envelope)
register_envelope("antigravity", "", antigravity_v1internal_envelope)
# Transport
register_transport_hook("antigravity", "gemini:chat", build_antigravity_url)
# Backward compat: allow existing endpoints that still use the old signature.
register_transport_hook("antigravity", "gemini:cli", build_antigravity_url)
# Auth
register_auth_enricher("antigravity", enrich_antigravity)
# Export
register_export_builder("antigravity", antigravity_export_builder)
# Model Fetcher
UpstreamModelsFetcherRegistry.register(
provider_types=["antigravity"],
fetcher=fetch_models_antigravity,
)
# Provider Format Capability
register_provider_behavior_variant("antigravity", cross_format=True)

View File

@@ -0,0 +1,108 @@
"""Shared Rust executor HTTP helper for Antigravity side calls."""
from __future__ import annotations
import json
from typing import Any
import httpx
from src.config.settings import config
from src.core.logger import logger
async def execute_antigravity_rust_http_request(
*,
method: str,
url: str,
headers: dict[str, str],
body: Any,
proxy_config: dict[str, Any] | None,
request_id: str,
provider_api_format: str,
timeout_seconds: float,
content_type: str | None = None,
) -> httpx.Response | None:
from src.services.request.execution_runtime_plan import (
ExecutionPlan,
ExecutionPlanBody,
ExecutionPlanTimeouts,
build_execution_plan_body,
build_proxy_snapshot,
)
from src.services.request.execution_runtime_client import (
ExecutionRuntimeClient,
ExecutionRuntimeClientError,
)
if config.execution_runtime_backend != "rust":
return None
final_headers = dict(headers)
if (
body is not None
and content_type
and not any(str(key).lower() == "content-type" for key in final_headers)
):
final_headers["content-type"] = content_type
timeout_ms = max(int(timeout_seconds * 1000), 1_000)
try:
proxy_snapshot = await build_proxy_snapshot(proxy_config, label="Antigravity")
result = await ExecutionRuntimeClient().execute_sync_json(
ExecutionPlan(
request_id=request_id,
candidate_id=None,
provider_name="antigravity",
provider_id="",
endpoint_id="",
key_id="",
method=method,
url=url,
headers=final_headers,
body=(
build_execution_plan_body(body, content_type=content_type)
if body is not None
else ExecutionPlanBody()
),
stream=False,
provider_api_format=provider_api_format,
client_api_format=provider_api_format,
model_name="antigravity",
content_type=content_type,
proxy=proxy_snapshot,
timeouts=ExecutionPlanTimeouts(
connect_ms=timeout_ms,
read_ms=timeout_ms,
write_ms=timeout_ms,
pool_ms=timeout_ms,
total_ms=timeout_ms,
),
)
)
except (ExecutionRuntimeClientError, httpx.HTTPError, json.JSONDecodeError) as exc:
logger.warning("Antigravity Rust HTTP fallback {} {}: {}", method, url, exc)
return None
except Exception as exc:
logger.warning("Antigravity Rust HTTP unexpected fallback {} {}: {}", method, url, exc)
return None
response_headers = dict(result.headers)
if result.response_json is not None:
response_headers.setdefault("content-type", "application/json")
response_body = json.dumps(result.response_json, ensure_ascii=False).encode("utf-8")
elif result.response_body_bytes is not None:
response_body = result.response_body_bytes
else:
response_body = b""
return httpx.Response(
status_code=result.status_code,
request=httpx.Request(method, url, headers=final_headers),
headers=response_headers,
content=response_body,
)
__all__ = ["execute_antigravity_rust_http_request"]

View File

@@ -0,0 +1,13 @@
"""Backward-compatible re-export for Antigravity thinking signature cache.
The implementation moved to `src.core.api_format.conversion.thinking_cache` to eliminate
core → services reverse dependencies.
"""
from src.core.api_format.conversion.thinking_cache import (
MIN_SIGNATURE_LENGTH,
ThinkingSignatureCache,
signature_cache,
)
__all__ = ["MIN_SIGNATURE_LENGTH", "ThinkingSignatureCache", "signature_cache"]

View File

@@ -0,0 +1,82 @@
"""Antigravity URL 可用性管理(带 TTL 自动恢复)。"""
from __future__ import annotations
import threading
import time
from src.services.provider.adapters.antigravity.constants import (
DAILY_BASE_URL,
PROD_BASE_URL,
SANDBOX_BASE_URL,
URL_UNAVAILABLE_TTL_SECONDS,
)
class URLAvailability:
"""管理 Antigravity API 端点可用性(进程内)。"""
_instance: "URLAvailability | None" = None
_lock = threading.Lock()
def __new__(cls) -> "URLAvailability":
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._init()
return cls._instance
def _init(self) -> None:
self._unavailable: dict[str, float] = {} # url -> recover_at(ts)
self._last_success: str | None = None
self._mu = threading.RLock()
def _prune(self, now: float | None = None) -> None:
now_ts = time.time() if now is None else now
self._unavailable = {u: t for u, t in self._unavailable.items() if t > now_ts}
def is_available(self, url: str) -> bool:
with self._mu:
self._prune()
return url not in self._unavailable
def get_ordered_urls(self, *, prefer_daily: bool = True) -> list[str]:
"""返回优先级排序的可用 URL 列表。
- 默认 daily 优先(通常限流更宽松)
- 最近成功的 URL 会被提升到最前
- 若全部被标记不可用,则返回 base_order允许继续尝试等待 TTL 自动恢复)
"""
with self._mu:
self._prune()
# Antigravity-Manager 顺序sandbox → daily → prod
base_order = (
[SANDBOX_BASE_URL, DAILY_BASE_URL, PROD_BASE_URL]
if prefer_daily
else [PROD_BASE_URL, SANDBOX_BASE_URL, DAILY_BASE_URL]
)
if self._last_success and self._last_success in base_order:
base_order.remove(self._last_success)
base_order.insert(0, self._last_success)
available = [u for u in base_order if u not in self._unavailable]
return available if available else base_order
def mark_success(self, url: str) -> None:
with self._mu:
self._last_success = url
self._unavailable.pop(url, None)
def mark_unavailable(self, url: str) -> None:
with self._mu:
self._unavailable[url] = time.time() + URL_UNAVAILABLE_TTL_SECONDS
if self._last_success == url:
self._last_success = None
url_availability = URLAvailability()
__all__ = ["URLAvailability", "url_availability"]

View File

@@ -0,0 +1,3 @@
"""Claude Code provider adapter."""
__all__ = []

View File

@@ -0,0 +1,96 @@
"""Claude Code CLI client restriction.
When cli_only_enabled is True, only requests from genuine Claude Code CLI
clients are allowed. Non-CLI traffic receives a 403 response.
"""
from __future__ import annotations
import contextvars
from typing import Any
from src.core.logger import logger
# Contextvar to carry the original request headers into the envelope layer.
_original_request_headers: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar(
"claude_code_original_request_headers",
default=None,
)
def set_original_request_headers(headers: dict[str, str] | None) -> None:
_original_request_headers.set(headers)
def get_original_request_headers() -> dict[str, str] | None:
return _original_request_headers.get()
# Known Claude Code CLI User-Agent patterns.
_CLI_USER_AGENT_PATTERNS = (
"claude-code",
"claudecode",
"claude_code",
)
# Known originator / x-app values indicating CLI usage.
_CLI_APP_VALUES = {"cli"}
def is_claude_code_client(headers: dict[str, Any]) -> bool:
"""Detect whether the request originates from a Claude Code CLI client.
Detection signals (any match is sufficient):
1. User-Agent contains a known Claude Code CLI pattern
2. x-app header equals "cli"
"""
# Normalize header keys to lowercase for case-insensitive matching.
lower_headers = {k.lower(): v for k, v in headers.items()}
# Check User-Agent
ua = str(lower_headers.get("user-agent", "")).lower()
for pattern in _CLI_USER_AGENT_PATTERNS:
if pattern in ua:
return True
# Check x-app header
x_app = str(lower_headers.get("x-app", "")).strip().lower()
if x_app in _CLI_APP_VALUES:
return True
return False
def enforce_cli_only(cli_only_enabled: bool) -> None:
"""Enforce CLI-only restriction if enabled.
Reads original request headers from contextvar, checks whether the
client is a Claude Code CLI, and raises HTTPException(403) if not.
"""
if not cli_only_enabled:
return
headers = get_original_request_headers()
if headers is None:
# No headers available; skip enforcement (should not happen in normal flow).
logger.debug("CLI-only check skipped: no request headers in context")
return
if is_claude_code_client(headers):
return
from fastapi import HTTPException
logger.info("CLI-only restriction: rejected non-CLI client")
raise HTTPException(
status_code=403,
detail="This endpoint only accepts requests from Claude Code CLI clients.",
)
__all__ = [
"enforce_cli_only",
"get_original_request_headers",
"is_claude_code_client",
"set_original_request_headers",
]

View File

@@ -0,0 +1,49 @@
"""Claude Code adapter constants."""
from __future__ import annotations
CLAUDE_MESSAGES_PATH = "/v1/messages"
DEFAULT_ANTHROPIC_VERSION = "2023-06-01"
DEFAULT_ACCEPT = "application/json"
STREAM_HELPER_METHOD = "stream"
SESSION_ID_MASKING_TTL_SECONDS = 15 * 60
# Claude Code OAuth required betas.
BETA_CLAUDE_CODE = "claude-code-20250219"
BETA_OAUTH = "oauth-2025-04-20"
BETA_INTERLEAVED_THINKING = "interleaved-thinking-2025-05-14"
BETA_CONTEXT_1M = "context-1m-2025-08-07"
CLAUDE_CODE_REQUIRED_BETA_TOKENS: tuple[str, ...] = (
BETA_CLAUDE_CODE,
BETA_OAUTH,
BETA_INTERLEAVED_THINKING,
)
# Mimic headers observed from Claude Code traffic.
CLAUDE_CODE_DEFAULT_HEADERS: dict[str, str] = {
"X-Stainless-Lang": "js",
"X-Stainless-Package-Version": "0.70.0",
"X-Stainless-OS": "Linux",
"X-Stainless-Arch": "arm64",
"X-Stainless-Runtime": "node",
"X-Stainless-Runtime-Version": "v24.13.0",
"X-Stainless-Retry-Count": "0",
"X-Stainless-Timeout": "600",
"X-App": "cli",
"Anthropic-Dangerous-Direct-Browser-Access": "true",
}
__all__ = [
"BETA_CLAUDE_CODE",
"BETA_CONTEXT_1M",
"BETA_INTERLEAVED_THINKING",
"BETA_OAUTH",
"CLAUDE_CODE_DEFAULT_HEADERS",
"CLAUDE_CODE_REQUIRED_BETA_TOKENS",
"CLAUDE_MESSAGES_PATH",
"DEFAULT_ACCEPT",
"DEFAULT_ANTHROPIC_VERSION",
"SESSION_ID_MASKING_TTL_SECONDS",
"STREAM_HELPER_METHOD",
]

View File

@@ -0,0 +1,141 @@
"""Claude Code request context using contextvars."""
from __future__ import annotations
import contextvars
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from src.core.logger import logger
from src.models.admin_requests import ClaudeCodeAdvancedConfig
if TYPE_CHECKING:
from src.services.provider.pool.config import PoolConfig
@dataclass(frozen=True, slots=True)
class ClaudeCodeRequestContext:
is_stream: bool = False
# 使用 Key 级别作用域,确保会话限制按 OAuth 账号隔离。
scope_key: str | None = None
key_id: str | None = None
max_sessions: int | None = None
session_idle_timeout_minutes: int = 5
session_id_masking_enabled: bool = False
cache_ttl_override_enabled: bool = False
cache_ttl_override_target: str = "ephemeral"
cli_only_enabled: bool = False
# Account Pool fields
provider_id: str | None = None
pool_config: PoolConfig | None = None
session_uuid: str | None = None
_claude_code_request_context: contextvars.ContextVar[ClaudeCodeRequestContext | None] = (
contextvars.ContextVar(
"claude_code_request_context",
default=None,
)
)
def set_claude_code_request_context(ctx: ClaudeCodeRequestContext | None) -> None:
_claude_code_request_context.set(ctx)
def get_claude_code_request_context() -> ClaudeCodeRequestContext | None:
return _claude_code_request_context.get()
def build_claude_code_request_context(
*,
provider_config: Any,
key_id: str | None,
is_stream: bool,
provider_id: str | None = None,
) -> ClaudeCodeRequestContext:
"""根据 Provider.config 构建 Claude Code 请求上下文。"""
from src.services.provider.pool.config import parse_pool_config
normalized_key_id = str(key_id or "").strip() or None
advanced_config: ClaudeCodeAdvancedConfig | None = None
provider_config_dict = provider_config if isinstance(provider_config, dict) else {}
raw_advanced = provider_config_dict.get("claude_code_advanced")
if raw_advanced is not None:
try:
if isinstance(raw_advanced, ClaudeCodeAdvancedConfig):
advanced_config = raw_advanced
elif isinstance(raw_advanced, dict):
advanced_config = ClaudeCodeAdvancedConfig.model_validate(raw_advanced)
else:
logger.warning(
"Claude Code advanced config 类型无效: {},已忽略",
type(raw_advanced).__name__,
)
except Exception as exc:
logger.warning("Claude Code advanced config 解析失败,已忽略: {}", str(exc))
max_sessions = advanced_config.max_sessions if advanced_config else None
idle_timeout_minutes = (
advanced_config.session_idle_timeout_minutes
if advanced_config and advanced_config.session_idle_timeout_minutes is not None
else 5
)
session_id_masking_enabled = (
bool(advanced_config.session_id_masking_enabled) if advanced_config else False
)
cache_ttl_override_enabled = (
bool(advanced_config.cache_ttl_override_enabled) if advanced_config else False
)
cache_ttl_override_target = (
str(advanced_config.cache_ttl_override_target or "ephemeral")
if advanced_config
else "ephemeral"
)
cli_only_enabled = bool(advanced_config.cli_only_enabled) if advanced_config else False
# Parse pool config (None = non-pool provider, keep as None for semantic consistency)
pool_cfg = parse_pool_config(provider_config_dict)
return ClaudeCodeRequestContext(
is_stream=bool(is_stream),
scope_key=f"key:{normalized_key_id}" if normalized_key_id else None,
key_id=normalized_key_id,
max_sessions=max_sessions,
session_idle_timeout_minutes=idle_timeout_minutes,
session_id_masking_enabled=session_id_masking_enabled,
cache_ttl_override_enabled=cache_ttl_override_enabled,
cache_ttl_override_target=cache_ttl_override_target,
cli_only_enabled=cli_only_enabled,
provider_id=str(provider_id or "").strip() or None,
pool_config=pool_cfg,
)
def build_and_set_claude_code_request_context(
*,
provider_config: Any,
key_id: str | None,
is_stream: bool,
provider_id: str | None = None,
) -> ClaudeCodeRequestContext:
"""构建并写入 Claude Code 上下文。"""
ctx = build_claude_code_request_context(
provider_config=provider_config,
key_id=key_id,
is_stream=is_stream,
provider_id=provider_id,
)
set_claude_code_request_context(ctx)
return ctx
__all__ = [
"build_and_set_claude_code_request_context",
"build_claude_code_request_context",
"ClaudeCodeRequestContext",
"get_claude_code_request_context",
"set_claude_code_request_context",
]

View File

@@ -0,0 +1,677 @@
"""Claude Code upstream envelope hooks."""
from __future__ import annotations
import threading
import time
import uuid
from dataclasses import replace
from typing import Any
from src.clients.redis_client import get_redis_client, get_redis_client_sync
from src.config.settings import config
from src.core.exceptions import ConcurrencyLimitError
from src.core.logger import logger
from src.services.provider.adapters.claude_code.constants import (
BETA_CONTEXT_1M,
CLAUDE_CODE_DEFAULT_HEADERS,
CLAUDE_CODE_REQUIRED_BETA_TOKENS,
DEFAULT_ACCEPT,
DEFAULT_ANTHROPIC_VERSION,
SESSION_ID_MASKING_TTL_SECONDS,
STREAM_HELPER_METHOD,
)
from src.services.provider.adapters.claude_code.context import (
ClaudeCodeRequestContext,
get_claude_code_request_context,
set_claude_code_request_context,
)
from src.services.provider.request_context import get_current_fingerprint
_SESSION_MARKER = "_session_"
_DUMMY_THINKING_SIGNATURE = "skip_thought_signature_validator"
_session_runtime_lock = threading.Lock()
# key: scope_key -> {session_id -> last_seen_monotonic}
_active_sessions: dict[str, dict[str, float]] = {}
# key: scope_key -> (masked_session_uuid, expire_at_monotonic)
_masked_sessions: dict[str, tuple[str, float]] = {}
# 上限保护scope_key 总数超过此值时触发全局清理
_MAX_SCOPE_KEYS = 5000
# 全局清理间隔monotonic 秒),避免高频请求时每次都做全局扫描
_LAST_GLOBAL_CLEANUP: float = 0.0
_GLOBAL_CLEANUP_INTERVAL = 300.0 # 5 分钟
_REDIS_SESSION_KEY_PREFIX = "claude_code:sessions"
_REDIS_SESSION_RESERVE_LUA = """
local key = KEYS[1]
local sid = ARGV[1]
local now = tonumber(ARGV[2])
local expire_before = tonumber(ARGV[3])
local max_sessions = tonumber(ARGV[4])
local ttl_seconds = tonumber(ARGV[5])
redis.call("ZREMRANGEBYSCORE", key, "-inf", expire_before)
local exists = redis.call("ZSCORE", key, sid)
if exists then
redis.call("ZADD", key, now, sid)
redis.call("EXPIRE", key, ttl_seconds)
return {1, redis.call("ZCARD", key)}
end
local active = redis.call("ZCARD", key)
if active >= max_sessions then
return {0, active}
end
redis.call("ZADD", key, now, sid)
redis.call("EXPIRE", key, ttl_seconds)
return {1, active + 1}
"""
def merge_anthropic_beta_tokens(
incoming: str | None,
*,
required: tuple[str, ...] = CLAUDE_CODE_REQUIRED_BETA_TOKENS,
) -> str:
"""Merge required beta tokens and incoming anthropic-beta with deduplication."""
seen: set[str] = set()
merged: list[str] = []
def _append(token: str) -> None:
token = token.strip()
if not token or token in seen:
return
seen.add(token)
merged.append(token)
for token in required:
_append(token)
for token in str(incoming or "").split(","):
_append(token)
return ",".join(merged)
def _parse_stream_flag(raw_stream: Any) -> bool:
if isinstance(raw_stream, bool):
return raw_stream
return str(raw_stream).strip().lower() in {"1", "true", "yes", "on"}
def _get_metadata_user_id(request_body: dict[str, Any]) -> str | None:
metadata = request_body.get("metadata")
if not isinstance(metadata, dict):
return None
user_id = metadata.get("user_id")
if not isinstance(user_id, str):
return None
text = user_id.strip()
return text or None
def _set_metadata_user_id(request_body: dict[str, Any], user_id: str) -> None:
metadata = request_body.get("metadata")
if not isinstance(metadata, dict):
metadata = {}
request_body["metadata"] = metadata
metadata["user_id"] = user_id
def _extract_session_id(user_id: str) -> str | None:
idx = user_id.rfind(_SESSION_MARKER)
if idx == -1:
return None
session_id = user_id[idx + len(_SESSION_MARKER) :].strip()
return session_id or None
def _is_thinking_enabled(request_body: dict[str, Any]) -> bool:
thinking = request_body.get("thinking")
if not isinstance(thinking, dict):
return False
thinking_type = str(thinking.get("type") or "").strip().lower()
return thinking_type in {"enabled", "adaptive"}
def _sanitize_thinking_blocks(request_body: dict[str, Any]) -> None:
"""过滤可能导致 Claude Code 400 的无效 thinking 块。"""
messages = request_body.get("messages")
if not isinstance(messages, list) or not messages:
return
thinking_enabled = _is_thinking_enabled(request_body)
filtered_messages = 0
filtered_blocks = 0
for message in messages:
if not isinstance(message, dict):
continue
role = str(message.get("role") or "")
content = message.get("content")
if not isinstance(content, list):
continue
new_content: list[Any] = []
modified = False
for block in content:
if not isinstance(block, dict):
new_content.append(block)
continue
block_type = str(block.get("type") or "")
if block_type in {"thinking", "redacted_thinking"}:
keep = False
# 仅保留 assistant 且带真实 signature 的 thinking 块。
if thinking_enabled and role == "assistant":
signature = str(block.get("signature") or "").strip()
keep = bool(signature and signature != _DUMMY_THINKING_SIGNATURE)
if keep:
new_content.append(block)
else:
modified = True
filtered_blocks += 1
continue
# 兼容无 type 但带 thinking 字段的历史块,直接移除。
if not block_type and "thinking" in block:
modified = True
filtered_blocks += 1
continue
new_content.append(block)
if modified:
message["content"] = new_content
filtered_messages += 1
if filtered_blocks:
logger.info(
"Claude Code thinking 预过滤: messages={}, blocks={}, thinking_enabled={}",
filtered_messages,
filtered_blocks,
thinking_enabled,
)
def _get_or_create_masked_session(scope_key: str) -> str:
now = time.monotonic()
with _session_runtime_lock:
existing = _masked_sessions.get(scope_key)
if existing and existing[1] > now:
masked_session_id = existing[0]
else:
masked_session_id = str(uuid.uuid4())
_masked_sessions[scope_key] = (
masked_session_id,
now + SESSION_ID_MASKING_TTL_SECONDS,
)
return masked_session_id
def _apply_session_id_masking(request_body: dict[str, Any], *, scope_key: str) -> None:
user_id = _get_metadata_user_id(request_body)
if not user_id:
return
idx = user_id.rfind(_SESSION_MARKER)
if idx == -1:
return
masked_session_id = _get_or_create_masked_session(scope_key)
_set_metadata_user_id(
request_body,
user_id[: idx + len(_SESSION_MARKER)] + masked_session_id,
)
# -- Cache TTL Override -------------------------------------------------------
_VALID_CACHE_TTL_TARGETS = {"ephemeral", "1h"}
def _override_cache_control_in_blocks(blocks: list[Any], target: str) -> int:
"""Override cache_control TTL in a list of content blocks. Returns count of overrides.
According to Anthropic API docs, cache_control format is:
{"type": "ephemeral", "ttl": "5m" | "1h"}
``type`` is always "ephemeral"; the ``ttl`` field controls the actual duration.
When ttl is absent, the default is 5m (ephemeral).
"""
count = 0
for block in blocks:
if not isinstance(block, dict):
continue
cc = block.get("cache_control")
if not isinstance(cc, dict):
continue
# Ensure type is always "ephemeral"
if cc.get("type") != "ephemeral":
cc["type"] = "ephemeral"
if target == "ephemeral":
# Target is 5m (default) -- remove explicit ttl so it falls back to default
if "ttl" in cc:
del cc["ttl"]
count += 1
else:
# Target is "1h" -- set ttl explicitly
if cc.get("ttl") != target:
cc["ttl"] = target
count += 1
return count
def _apply_cache_ttl_override(request_body: dict[str, Any], target: str) -> None:
"""Force all cache_control entries to use a unified TTL type.
Prevents multi-user behavioral fingerprinting when sharing an OAuth account.
"""
if target not in _VALID_CACHE_TTL_TARGETS:
return
overridden = 0
# system prompt (can be string or list of blocks)
system = request_body.get("system")
if isinstance(system, list):
overridden += _override_cache_control_in_blocks(system, target)
# messages
messages = request_body.get("messages")
if isinstance(messages, list):
for msg in messages:
if not isinstance(msg, dict):
continue
content = msg.get("content")
if isinstance(content, list):
overridden += _override_cache_control_in_blocks(content, target)
# tools
tools = request_body.get("tools")
if isinstance(tools, list):
overridden += _override_cache_control_in_blocks(tools, target)
if overridden:
logger.debug("Cache TTL override: {} block(s) -> {}", overridden, target)
def _cleanup_stale_scope_keys(now: float, idle_seconds: int) -> None:
"""清理空 bucket 和过期的 _masked_sessions 条目(调用方须持有锁)。"""
global _LAST_GLOBAL_CLEANUP
if now - _LAST_GLOBAL_CLEANUP < _GLOBAL_CLEANUP_INTERVAL:
return
_LAST_GLOBAL_CLEANUP = now
# 清理所有 scope_key 下的过期 session并删除空 bucket
stale_keys = []
for sk, bucket in _active_sessions.items():
expired = [sid for sid, ts in bucket.items() if now - ts > idle_seconds]
for sid in expired:
bucket.pop(sid, None)
if not bucket:
stale_keys.append(sk)
for sk in stale_keys:
_active_sessions.pop(sk, None)
# 清理过期的 masked session 条目
expired_masked = [sk for sk, (_, exp) in _masked_sessions.items() if exp <= now]
for sk in expired_masked:
_masked_sessions.pop(sk, None)
total = len(_active_sessions) + len(_masked_sessions)
if total > 0 and (stale_keys or expired_masked):
logger.debug(
"Session 全局清理: 移除 {} 个 active scope + {} 个 masked scope, 剩余 {}",
len(stale_keys),
len(expired_masked),
total,
)
def _register_or_reject_session(
*,
scope_key: str,
session_id: str,
max_sessions: int,
idle_timeout_minutes: int,
) -> tuple[bool, int]:
now = time.monotonic()
idle_seconds = max(60, int(idle_timeout_minutes * 60))
with _session_runtime_lock:
# scope_key 总数超限或达到清理间隔时,执行全局清理
if (
len(_active_sessions) + len(_masked_sessions) > _MAX_SCOPE_KEYS
or now - _LAST_GLOBAL_CLEANUP >= _GLOBAL_CLEANUP_INTERVAL
):
_cleanup_stale_scope_keys(now, idle_seconds)
bucket = _active_sessions.setdefault(scope_key, {})
# 先清理当前 bucket 的过期会话,避免误判占用。
expired = [sid for sid, last_seen in bucket.items() if now - last_seen > idle_seconds]
for sid in expired:
bucket.pop(sid, None)
if session_id in bucket:
bucket[session_id] = now
return True, len(bucket)
if len(bucket) >= max_sessions:
return False, len(bucket)
bucket[session_id] = now
return True, len(bucket)
def _build_session_limit_error(
*,
max_sessions: int,
active_count: int,
key_id: str | None,
) -> ConcurrencyLimitError:
return ConcurrencyLimitError(
message=(f"Claude Code 活跃会话数已达上限({max_sessions})。当前活跃会话: {active_count}"),
key_id=key_id,
)
def _redis_session_key(scope_key: str) -> str:
return f"{_REDIS_SESSION_KEY_PREFIX}:{scope_key}"
def _parse_redis_session_result(raw: Any) -> tuple[bool, int] | None:
if not isinstance(raw, (list, tuple)) or len(raw) < 2:
return None
try:
allowed = int(raw[0]) == 1
active_count = int(raw[1])
except Exception:
return None
return allowed, active_count
def _enforce_session_controls(
request_body: dict[str, Any],
ctx: ClaudeCodeRequestContext,
*,
enforce_max_sessions: bool = True,
) -> None:
"""同步执行会话限制 + masking。
当 ``enforce_max_sessions=False``(将由 ``enforce_distributed_session_controls``
异步接管)时仅做 maskingmasking 始终在会话限制检查之后执行,避免
用被伪装后的 session_id 做计数。
"""
scope_key = str(ctx.scope_key or "").strip()
if not scope_key:
return
# 先基于真实 session_id 做会话限制检查。
if enforce_max_sessions and ctx.max_sessions and ctx.max_sessions > 0:
user_id = _get_metadata_user_id(request_body)
if user_id:
session_id = _extract_session_id(user_id) or user_id
allowed, active_count = _register_or_reject_session(
scope_key=scope_key,
session_id=session_id,
max_sessions=ctx.max_sessions,
idle_timeout_minutes=ctx.session_idle_timeout_minutes,
)
if not allowed:
raise _build_session_limit_error(
max_sessions=ctx.max_sessions,
active_count=active_count,
key_id=ctx.key_id,
)
if not enforce_max_sessions:
# 分布式模式下 masking 延迟到 enforce_distributed_session_controls 中执行,
# 避免 wrap_request 提前改写 user_id 导致分布式检查拿到伪装后的 session_id。
return
# 仅在本地模式下立即 masking。
if ctx.session_id_masking_enabled:
_apply_session_id_masking(request_body, scope_key=scope_key)
def _is_distributed_session_control_available() -> bool:
try:
return get_redis_client_sync() is not None
except Exception:
return False
async def enforce_distributed_session_controls(
request_body: dict[str, Any],
ctx: ClaudeCodeRequestContext | None,
) -> None:
"""异步执行会话限制 + masking。
优先使用 Redis多实例共享Redis 不可用时回退到进程内计数。
masking 在会话限制检查通过后执行,确保计数使用真实 session_id。
"""
if ctx is None:
return
scope_key = str(ctx.scope_key or "").strip()
if not scope_key:
# 即使无 scope_key 也无法做 masking需要 scope_key 作为 key直接返回。
return
if not ctx.max_sessions or ctx.max_sessions <= 0:
# 无会话限制,仅做 masking。
if ctx.session_id_masking_enabled:
_apply_session_id_masking(request_body, scope_key=scope_key)
return
# 基于真实 user_id 提取 session_id 做限制检查。
user_id = _get_metadata_user_id(request_body)
if not user_id:
return
session_id = _extract_session_id(user_id) or user_id
idle_seconds = max(60, int(ctx.session_idle_timeout_minutes * 60))
redis_ttl = idle_seconds + 300
now = int(time.time())
expire_before = now - idle_seconds
redis_client = await get_redis_client(require_redis=False)
if redis_client is not None:
try:
raw_result = await redis_client.eval(
_REDIS_SESSION_RESERVE_LUA,
1,
_redis_session_key(scope_key),
session_id,
str(now),
str(expire_before),
str(ctx.max_sessions),
str(redis_ttl),
)
parsed = _parse_redis_session_result(raw_result)
if parsed is None:
raise ValueError(f"invalid redis eval result: {raw_result!r}")
allowed, active_count = parsed
if not allowed:
raise _build_session_limit_error(
max_sessions=ctx.max_sessions,
active_count=active_count,
key_id=ctx.key_id,
)
# 会话限制通过后再做 masking。
if ctx.session_id_masking_enabled:
_apply_session_id_masking(request_body, scope_key=scope_key)
return
except ConcurrencyLimitError:
raise
except Exception as exc:
logger.warning("Claude Code 分布式会话控制失败,回退本地计数: {}", str(exc))
allowed, active_count = _register_or_reject_session(
scope_key=scope_key,
session_id=session_id,
max_sessions=ctx.max_sessions,
idle_timeout_minutes=ctx.session_idle_timeout_minutes,
)
if not allowed:
raise _build_session_limit_error(
max_sessions=ctx.max_sessions,
active_count=active_count,
key_id=ctx.key_id,
)
# 会话限制通过后再做 masking。
if ctx.session_id_masking_enabled:
_apply_session_id_masking(request_body, scope_key=scope_key)
class ClaudeCodeEnvelope:
"""Provider envelope hooks for Claude Code OAuth upstream."""
name = "claude:cli"
def extra_headers(self) -> dict[str, str] | None:
ctx = get_claude_code_request_context()
is_stream = bool(ctx.is_stream) if ctx else False
fp = get_current_fingerprint()
headers = dict(CLAUDE_CODE_DEFAULT_HEADERS)
headers["Accept"] = DEFAULT_ACCEPT
headers["anthropic-version"] = DEFAULT_ANTHROPIC_VERSION
headers["anthropic-beta"] = merge_anthropic_beta_tokens(None)
if is_stream:
headers["x-stainless-helper-method"] = STREAM_HELPER_METHOD
if fp:
headers["X-Stainless-Package-Version"] = fp.stainless_package_version
headers["X-Stainless-OS"] = fp.stainless_os
headers["X-Stainless-Arch"] = fp.stainless_arch
headers["X-Stainless-Runtime-Version"] = fp.stainless_runtime_version
headers["X-Stainless-Timeout"] = fp.stainless_timeout
headers["User-Agent"] = fp.user_agent
else:
ua = str(getattr(config, "internal_user_agent_claude_cli", "") or "").strip()
if ua:
headers["User-Agent"] = ua
return headers
def wrap_request(
self,
request_body: dict[str, Any],
*,
model: str, # noqa: ARG002
url_model: str | None,
decrypted_auth_config: dict[str, Any] | None, # noqa: ARG002
) -> tuple[dict[str, Any], str | None]:
raw_stream = request_body.get("stream", False)
is_stream = _parse_stream_flag(raw_stream)
ctx = get_claude_code_request_context()
if ctx is None:
ctx = ClaudeCodeRequestContext()
# CLI-only restriction: reject non-CLI clients early.
if ctx.cli_only_enabled:
from src.services.provider.adapters.claude_code.client_restriction import (
enforce_cli_only,
)
enforce_cli_only(ctx.cli_only_enabled)
# Extract session_uuid from metadata.user_id for pool sticky session.
session_uuid: str | None = None
user_id = _get_metadata_user_id(request_body)
if user_id:
session_uuid = _extract_session_id(user_id)
ctx = replace(ctx, is_stream=is_stream, session_uuid=session_uuid)
set_claude_code_request_context(ctx)
_sanitize_thinking_blocks(request_body)
# Cache TTL override: unify cache_control types to prevent behavioral fingerprinting.
if ctx.cache_ttl_override_enabled:
_apply_cache_ttl_override(request_body, ctx.cache_ttl_override_target)
_enforce_session_controls(
request_body,
ctx,
enforce_max_sessions=not _is_distributed_session_control_available(),
)
return request_body, url_model
def unwrap_response(self, data: Any) -> Any:
return data
def postprocess_unwrapped_response(self, *, model: str, data: Any) -> None: # noqa: ARG002
return
def capture_selected_base_url(self) -> str | None:
return None
def on_http_status(self, *, base_url: str | None, status_code: int) -> None: # noqa: ARG002
return
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None: # noqa: ARG002
return
def force_stream_rewrite(self) -> bool:
return False
# ------------------------------------------------------------------
# Optional lifecycle hooks
# ------------------------------------------------------------------
def prepare_context(
self,
*,
provider_config: Any,
key_id: str,
user_api_key_id: str | None = None, # noqa: ARG002
is_stream: bool,
provider_id: str | None = None,
key: Any = None,
) -> str | None:
from src.services.provider.adapters.claude_code.context import (
build_and_set_claude_code_request_context,
)
# 在 envelope 层设置指纹 context var仅 Claude Code 需要指纹注入)
if key is not None:
from src.services.provider.fingerprint import ensure_key_fingerprint
from src.services.provider.request_context import set_current_fingerprint
set_current_fingerprint(ensure_key_fingerprint(key, persist_if_missing=True))
build_and_set_claude_code_request_context(
provider_config=provider_config,
key_id=key_id,
is_stream=is_stream,
provider_id=provider_id,
)
fp = get_current_fingerprint()
if fp:
return fp.impersonate
return None
async def post_wrap_request(self, request_body: dict[str, Any]) -> None:
await enforce_distributed_session_controls(
request_body,
get_claude_code_request_context(),
)
def excluded_beta_tokens(self) -> frozenset[str]:
return frozenset({BETA_CONTEXT_1M})
claude_code_envelope = ClaudeCodeEnvelope()
__all__ = [
"ClaudeCodeEnvelope",
"claude_code_envelope",
"enforce_distributed_session_controls",
"merge_anthropic_beta_tokens",
]

View File

@@ -0,0 +1,63 @@
"""Claude Code provider plugin."""
from __future__ import annotations
from typing import Any
from urllib.parse import urlencode
from src.services.provider.adapters.claude_code.constants import CLAUDE_MESSAGES_PATH
from src.services.provider.preset_models import create_preset_models_fetcher
fetch_models_claude_code = create_preset_models_fetcher("claude_code")
def build_claude_code_url(
endpoint: Any,
*,
is_stream: bool,
effective_query_params: dict[str, Any],
**_kwargs: Any,
) -> str:
"""Build Claude Code upstream URL and avoid duplicate /v1/messages suffix."""
_ = is_stream
base = str(getattr(endpoint, "base_url", "") or "").rstrip("/")
if base.endswith(CLAUDE_MESSAGES_PATH) or base.endswith("/messages"):
url = base
elif base.endswith("/v1"):
url = f"{base}/messages"
else:
url = f"{base}{CLAUDE_MESSAGES_PATH}"
if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
return url
def register_all() -> None:
"""Register Claude Code hooks into shared registries."""
from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
from src.services.provider.adapters.claude_code.envelope import claude_code_envelope
from src.services.provider.envelope import register_envelope
from src.services.provider.transport import register_transport_hook
register_envelope("claude_code", "claude:cli", claude_code_envelope)
register_envelope("claude_code", "", claude_code_envelope)
register_transport_hook("claude_code", "claude:cli", build_claude_code_url)
UpstreamModelsFetcherRegistry.register(
provider_types=["claude_code"],
fetcher=fetch_models_claude_code,
)
from src.services.provider.adapters.claude_code.pool_hook import claude_code_pool_hook
from src.services.provider.pool.hooks import register_pool_hook
register_pool_hook("claude_code", claude_code_pool_hook)
__all__ = ["build_claude_code_url", "fetch_models_claude_code", "register_all"]

View File

@@ -0,0 +1,6 @@
"""Backward compat shim -- canonical definitions moved to src.services.provider.pool.config."""
from src.services.provider.pool.config import * # noqa: F401,F403
from src.services.provider.pool.config import PoolConfig, UnschedulableRule, parse_pool_config
__all__ = ["PoolConfig", "UnschedulableRule", "parse_pool_config"]

View File

@@ -0,0 +1,10 @@
"""Backward compat shim -- canonical definitions moved to src.services.provider.pool.cost_tracker."""
from src.services.provider.pool.cost_tracker import ( # noqa: F401
get_window_usage,
is_approaching_limit,
is_at_limit,
record_usage,
)
__all__ = ["record_usage", "get_window_usage", "is_at_limit", "is_approaching_limit"]

View File

@@ -0,0 +1,6 @@
"""Backward compat shim -- canonical definitions moved to src.services.provider.pool.health_policy."""
from src.services.provider.pool.health_policy import * # noqa: F401,F403
from src.services.provider.pool.health_policy import apply_health_policy # noqa: F811
__all__ = ["apply_health_policy"]

View File

@@ -0,0 +1,27 @@
"""Claude Code pool scheduling hook."""
from __future__ import annotations
from typing import Any
class ClaudeCodePoolHook:
"""Pool scheduling hook for Claude Code providers.
Extracts the session UUID from ``metadata.user_id`` which follows the
pattern ``<user>_session_<uuid>``.
"""
name = "claude_code"
def extract_session_uuid(self, request_body: dict[str, Any]) -> str | None:
metadata = request_body.get("metadata")
if isinstance(metadata, dict):
user_id = metadata.get("user_id")
if isinstance(user_id, str) and "_session_" in user_id:
idx = user_id.rfind("_session_")
return user_id[idx + len("_session_") :].strip() or None
return None
claude_code_pool_hook = ClaudeCodePoolHook()

View File

@@ -0,0 +1,8 @@
"""Backward compat shim -- canonical definitions moved to src.services.provider.pool.manager."""
from src.services.provider.pool.manager import * # noqa: F401,F403
from src.services.provider.pool.manager import PoolManager
ClaudeCodePoolManager = PoolManager # noqa: F811
__all__ = ["PoolManager", "ClaudeCodePoolManager"]

View File

@@ -0,0 +1,9 @@
"""Backward compat shim -- canonical definitions moved to src.services.provider.pool.oauth_cache."""
from src.services.provider.pool.oauth_cache import ( # noqa: F401
cache_token,
get_cached_token,
invalidate_token,
)
__all__ = ["get_cached_token", "cache_token", "invalidate_token"]

View File

@@ -0,0 +1,23 @@
"""Backward compat shim -- canonical definitions moved to src.services.provider.pool.redis_ops."""
from src.services.provider.pool.redis_ops import * # noqa: F401,F403
from src.services.provider.pool.redis_ops import (
add_cost_entry,
batch_get_cooldowns,
cache_oauth_token,
clear_cooldown,
clear_cost,
delete_sticky_binding,
get_cached_oauth_token,
get_cooldown,
get_cooldown_ttl,
get_cost_window_total,
get_key_sticky_count,
get_lru_scores,
get_sticky_binding,
get_sticky_session_count,
invalidate_oauth_token_cache,
set_cooldown,
set_sticky_binding,
touch_lru,
)

View File

@@ -0,0 +1,3 @@
"""Codex provider integration package."""
__all__ = []

View File

@@ -0,0 +1,55 @@
"""Codex request-scoped context.
Codex only needs a small amount of per-request runtime state that does not belong in
the outbound payload itself. Today that state is the compact-mode flag used by the
transport and upstream stream-policy layers.
"""
from __future__ import annotations
import contextvars
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class CodexRequestContext:
"""Per-request context for the Codex adapter."""
is_compact: bool = False
_codex_request_context: contextvars.ContextVar[CodexRequestContext | None] = contextvars.ContextVar(
"codex_request_context",
default=None,
)
def set_codex_request_context(ctx: CodexRequestContext | None) -> None:
_codex_request_context.set(ctx)
def get_codex_request_context() -> CodexRequestContext | None:
return _codex_request_context.get()
def is_codex_compact_request(*, endpoint_sig: str | None = None) -> bool:
"""Return whether the current Codex request should use compact semantics.
Modern configurations use a dedicated ``openai:compact`` endpoint. Older ones may
still route compact traffic through ``openai:cli`` and rely on request-scoped
context instead.
"""
normalized_sig = str(endpoint_sig or "").strip().lower()
if normalized_sig == "openai:compact":
return True
ctx = get_codex_request_context()
return bool(ctx and ctx.is_compact)
__all__ = [
"CodexRequestContext",
"get_codex_request_context",
"is_codex_compact_request",
"set_codex_request_context",
]

View File

@@ -0,0 +1,371 @@
"""Codex provider plugin — 统一注册入口。
将 Codex 对各通用 registry / capability registry 的注册集中在一个文件中:
- Transport Hook (URL 构建)
- Auth Enricher (OAuth enrichment)
- Provider Format Capability默认 body_rules
- Model Fetcher (fixed catalog — Codex has no /v1/models endpoint)
新增 provider 时参照此文件创建对应的 plugin.py 即可。
"""
from __future__ import annotations
import hashlib
from collections.abc import Mapping
from typing import Any
from urllib.parse import urlencode
from src.core.logger import logger
# ---------------------------------------------------------------------------
# Preset model catalog
# ---------------------------------------------------------------------------
# Codex upstream (chatgpt.com/backend-api/codex) 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
fetch_models_codex = create_preset_models_fetcher("codex")
# ---------------------------------------------------------------------------
# Transport Hook
# ---------------------------------------------------------------------------
def _get_header_value(headers: Mapping[str, Any] | None, header_name: str) -> str | None:
if not isinstance(headers, Mapping):
return None
target = str(header_name or "").strip().lower()
if not target:
return None
for name, value in headers.items():
if str(name or "").strip().lower() != target:
continue
normalized = str(value or "").strip()
return normalized or None
return None
def _build_short_header_id(seed: str) -> str:
return hashlib.sha256(seed.encode()).hexdigest()[:16]
def _normalize_codex_debug_organizations(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
items: list[dict[str, Any]] = []
for raw in value:
if not isinstance(raw, Mapping):
continue
normalized: dict[str, Any] = {}
org_id = str(raw.get("id") or "").strip()
if org_id:
normalized["id"] = org_id
title = str(raw.get("title") or "").strip()
if title:
normalized["title"] = title
role = str(raw.get("role") or "").strip()
if role:
normalized["role"] = role
if "is_default" in raw:
normalized["is_default"] = bool(raw.get("is_default"))
if normalized:
items.append(normalized)
return items
def _build_safe_codex_debug_snapshot(values: Mapping[str, Any] | None) -> dict[str, Any]:
if not isinstance(values, Mapping):
return {}
snapshot: dict[str, Any] = {}
for field in (
"email",
"account_id",
"account_user_id",
"plan_type",
"user_id",
"account_name",
):
raw = values.get(field)
if isinstance(raw, str):
normalized = raw.strip()
if normalized:
snapshot[field] = normalized
elif raw is not None:
snapshot[field] = raw
organizations = _normalize_codex_debug_organizations(values.get("organizations"))
if organizations:
snapshot["organizations"] = organizations
return snapshot
def _build_codex_headers(
request_body: Any,
original_headers: Mapping[str, Any] | None,
*,
include_conversation_id: bool,
decrypted_auth_config: dict[str, Any] | None = None,
) -> dict[str, str]:
"""Codex upstream: chatgpt-account-id + session_id + conversation_id."""
headers: dict[str, str] = {}
if decrypted_auth_config:
account_id = str(decrypted_auth_config.get("account_id") or "").strip()
if account_id:
headers["chatgpt-account-id"] = account_id
if isinstance(request_body, dict):
cache_key = str(request_body.get("prompt_cache_key") or "").strip()
if cache_key:
short_id = _build_short_header_id(cache_key)
if not _get_header_value(original_headers, "session_id"):
headers["session_id"] = short_id
if include_conversation_id and not _get_header_value(
original_headers, "conversation_id"
):
headers["conversation_id"] = short_id
return headers
def build_codex_cli_headers(
request_body: Any,
original_headers: Mapping[str, Any] | None,
*,
decrypted_auth_config: dict[str, Any] | None = None,
) -> dict[str, str]:
from src.services.provider.adapters.codex.context import is_codex_compact_request
return _build_codex_headers(
request_body,
original_headers,
include_conversation_id=not is_codex_compact_request(endpoint_sig="openai:cli"),
decrypted_auth_config=decrypted_auth_config,
)
def build_codex_compact_headers(
request_body: Any,
original_headers: Mapping[str, Any] | None,
*,
decrypted_auth_config: dict[str, Any] | None = None,
) -> dict[str, str]:
return _build_codex_headers(
request_body,
original_headers,
include_conversation_id=False,
decrypted_auth_config=decrypted_auth_config,
)
def build_codex_url(
endpoint: Any,
*,
is_stream: bool,
effective_query_params: dict[str, Any],
**_kwargs: Any,
) -> str:
"""构建 Codex OAuth URL。
Codex upstream (chatgpt.com/backend-api/codex) 使用 /responses
而非标准 OpenAI 的 /v1/responses。compact 模式使用 /responses/compact。
"""
_ = is_stream # Codex 不需要根据 stream 切换路径
endpoint_sig = str(getattr(endpoint, "api_format", "") or "").strip().lower()
from src.services.provider.adapters.codex.context import is_codex_compact_request
is_compact = is_codex_compact_request(endpoint_sig=endpoint_sig)
base = str(endpoint.base_url).rstrip("/")
# 如果用户已在 base_url 中包含了 /responses不要重复追加
if base.endswith("/responses"):
url = f"{base}/compact" if is_compact else base
elif base.endswith("/responses/compact"):
url = base if is_compact else base.removesuffix("/compact")
else:
suffix = "/responses/compact" if is_compact else "/responses"
url = f"{base}{suffix}"
if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
return url
# ---------------------------------------------------------------------------
# Auth Enricher
# ---------------------------------------------------------------------------
async def enrich_codex(
auth_config: dict[str, Any],
token_response: dict[str, Any],
access_token: str,
proxy_config: dict[str, Any] | None,
) -> dict[str, Any]:
"""Codex auth_config enrichment: parse token claims -> account/team identity metadata."""
from src.core.provider_oauth_utils import (
fetch_openai_account_name,
parse_codex_id_token,
)
def _read_non_empty_str(*values: Any) -> str | None:
for value in values:
if isinstance(value, str):
normalized = value.strip()
if normalized:
return normalized
return None
# Prefer explicit fields if token endpoint returns them directly.
direct_account_id = _read_non_empty_str(
token_response.get("account_id"),
token_response.get("accountId"),
token_response.get("chatgpt_account_id"),
token_response.get("chatgptAccountId"),
)
if direct_account_id and not auth_config.get("account_id"):
auth_config["account_id"] = direct_account_id
direct_account_user_id = _read_non_empty_str(
token_response.get("account_user_id"),
token_response.get("accountUserId"),
token_response.get("chatgpt_account_user_id"),
token_response.get("chatgptAccountUserId"),
)
if direct_account_user_id and not auth_config.get("account_user_id"):
auth_config["account_user_id"] = direct_account_user_id
direct_plan_type = _read_non_empty_str(
token_response.get("plan_type"),
token_response.get("planType"),
token_response.get("chatgpt_plan_type"),
token_response.get("chatgptPlanType"),
)
if direct_plan_type and not auth_config.get("plan_type"):
auth_config["plan_type"] = direct_plan_type
direct_user_id = _read_non_empty_str(
token_response.get("user_id"),
token_response.get("userId"),
token_response.get("chatgpt_user_id"),
token_response.get("chatgptUserId"),
)
if direct_user_id and not auth_config.get("user_id"):
auth_config["user_id"] = direct_user_id
direct_email = _read_non_empty_str(token_response.get("email"))
if direct_email and not auth_config.get("email"):
auth_config["email"] = direct_email
direct_snapshot = _build_safe_codex_debug_snapshot(
{
"email": direct_email,
"account_id": direct_account_id,
"account_user_id": direct_account_user_id,
"plan_type": direct_plan_type,
"user_id": direct_user_id,
}
)
logger.debug(
"Codex enrich_auth_config: id_token_present={} access_token_present={} token_keys={}",
bool(token_response.get("id_token") or token_response.get("idToken")),
bool(token_response.get("access_token") or token_response.get("accessToken")),
list(token_response.keys()),
)
logger.debug("Codex direct token response values: {}", direct_snapshot)
token_candidates = [
("id_token", token_response.get("id_token")),
("idToken", token_response.get("idToken")),
("access_token", token_response.get("access_token")),
("accessToken", token_response.get("accessToken")),
]
for source_name, token_payload in token_candidates:
codex_info = parse_codex_id_token(token_payload)
if not codex_info:
continue
logger.debug(
"Codex parsed token values: source={} fields={} values={}",
source_name,
list(codex_info.keys()),
_build_safe_codex_debug_snapshot(codex_info),
)
for key, value in codex_info.items():
if not auth_config.get(key):
auth_config[key] = value
account_id = _read_non_empty_str(auth_config.get("account_id"))
if account_id:
account_name = await fetch_openai_account_name(
access_token,
account_id,
proxy_config=proxy_config,
timeout_seconds=10.0,
)
logger.debug(
"Codex account_name lookup: account_id={} resolved_account_name={}",
account_id,
account_name,
)
if account_name:
auth_config["account_name"] = account_name
logger.debug(
"Codex enrich_auth_config final metadata: {}",
_build_safe_codex_debug_snapshot(auth_config),
)
return auth_config
# ---------------------------------------------------------------------------
# Unified Registration
# ---------------------------------------------------------------------------
def register_all() -> None:
"""一次性注册 Codex 的所有 hooks 到各通用 registry。"""
from src.core.api_format.capabilities import register_provider_default_body_rules
from src.core.provider_oauth_utils import register_auth_enricher
from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
from src.services.provider.transport import register_transport_hook
from src.services.provider.upstream_headers import register_upstream_headers_hook
# Transport
register_transport_hook("codex", "openai:cli", build_codex_url)
register_transport_hook("codex", "openai:compact", build_codex_url)
register_upstream_headers_hook("codex", "openai:cli", build_codex_cli_headers)
register_upstream_headers_hook("codex", "openai:compact", build_codex_compact_headers)
# Auth
register_auth_enricher("codex", enrich_codex)
# Provider Format Capability默认 body_rules
from src.core.api_format.metadata import CODEX_DEFAULT_BODY_RULES
register_provider_default_body_rules("codex", "openai:cli", CODEX_DEFAULT_BODY_RULES)
# Export: Codex uses the default export builder (strip null + temp fields)
# No need to register a custom one — the default in export.py suffices.
# Model Fetcher
UpstreamModelsFetcherRegistry.register(
provider_types=["codex"],
fetcher=fetch_models_codex,
)

View File

@@ -0,0 +1,5 @@
"""GeminiCLI provider adapter package."""
from .plugin import register_all
__all__ = ["register_all"]

View File

@@ -0,0 +1,221 @@
"""GeminiCLI upstream client helpers."""
from __future__ import annotations
import asyncio
from typing import Any
from src.clients.http_client import HTTPClientPool
from src.core.logger import logger
from src.services.provider.adapters.gemini_cli.constants import (
PROD_BASE_URL,
get_v1internal_extra_headers,
)
from src.services.provider.adapters.gemini_cli.rust_http import (
execute_gemini_cli_rust_http_request,
)
_CODE_ASSIST_METADATA = {
"ideType": "ANTIGRAVITY",
"platform": "PLATFORM_UNSPECIFIED",
"pluginType": "GEMINI",
}
def _extract_tier_raw(tier_obj: Any) -> str:
"""Extract raw tier string from loadCodeAssist response objects."""
if isinstance(tier_obj, str) and tier_obj.strip():
return tier_obj.strip()
if isinstance(tier_obj, dict):
for key in ("id", "tierType"):
value = tier_obj.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def extract_plan_type(data: dict[str, Any]) -> str | None:
"""Best-effort normalized plan type for GeminiCLI OAuth accounts."""
from src.core.oauth_plan import normalize_oauth_plan_type
for key in ("paidTier", "currentTier"):
raw = _extract_tier_raw(data.get(key))
normalized = normalize_oauth_plan_type(raw)
if normalized:
return normalized
return None
def extract_project_id(data: dict[str, Any]) -> str:
"""Extract project_id from loadCodeAssist/onboardUser responses."""
raw = data.get("cloudaicompanionProject")
if isinstance(raw, str) and raw.strip():
return raw.strip()
if isinstance(raw, dict):
project_id = raw.get("id", "")
if isinstance(project_id, str) and project_id.strip():
return project_id.strip()
return ""
def extract_tier_id(data: dict[str, Any]) -> str:
"""Choose a tier ID for onboarding when the account is not activated."""
allowed_tiers = data.get("allowedTiers")
if not isinstance(allowed_tiers, list):
return ""
for tier in allowed_tiers:
if isinstance(tier, dict) and tier.get("isDefault") is True:
tier_id = tier.get("id", "")
if isinstance(tier_id, str) and tier_id.strip():
return tier_id.strip()
for tier in allowed_tiers:
if isinstance(tier, dict):
tier_id = tier.get("id", "")
if isinstance(tier_id, str) and tier_id.strip():
return tier_id.strip()
return ""
async def load_code_assist(
access_token: str,
proxy_config: dict[str, Any] | None = None,
*,
timeout_seconds: float = 10.0,
) -> dict[str, Any]:
"""Load GeminiCLI account metadata from v1internal:loadCodeAssist."""
if not access_token:
raise ValueError("missing access_token")
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
**get_v1internal_extra_headers(),
}
url = f"{PROD_BASE_URL.rstrip('/')}/v1internal:loadCodeAssist"
body = {"metadata": _CODE_ASSIST_METADATA}
resp = await execute_gemini_cli_rust_http_request(
method="POST",
url=url,
headers=headers,
body=body,
proxy_config=proxy_config,
request_id="gemini-cli:load-code-assist",
provider_api_format="gemini_cli:load_code_assist",
timeout_seconds=timeout_seconds,
content_type="application/json",
)
if resp is None:
client = await HTTPClientPool.get_proxy_client(proxy_config)
resp = await client.post(
url,
json=body,
headers=headers,
timeout=timeout_seconds,
)
if 200 <= resp.status_code < 300:
data = resp.json()
return data if isinstance(data, dict) else {}
raise RuntimeError(
f"loadCodeAssist failed: status={resp.status_code} body={resp.text[:200] if resp.text else ''}"
)
async def onboard_user(
access_token: str,
*,
tier_id: str,
proxy_config: dict[str, Any] | None = None,
timeout_seconds: float = 30.0,
max_attempts: int = 5,
poll_interval: float = 2.0,
) -> str:
"""Activate GeminiCLI user and fetch project_id via v1internal:onboardUser."""
if not access_token:
raise ValueError("missing access_token")
if not tier_id:
raise ValueError("missing tier_id")
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
**get_v1internal_extra_headers(),
}
body = {
"tierId": tier_id,
"metadata": _CODE_ASSIST_METADATA,
}
url = f"{PROD_BASE_URL.rstrip('/')}/v1internal:onboardUser"
for attempt in range(1, max_attempts + 1):
resp = await execute_gemini_cli_rust_http_request(
method="POST",
url=url,
headers=headers,
body=body,
proxy_config=proxy_config,
request_id=f"gemini-cli:onboard-user:{tier_id}:{attempt}",
provider_api_format="gemini_cli:onboard_user",
timeout_seconds=timeout_seconds,
content_type="application/json",
)
if resp is None:
client = await HTTPClientPool.get_proxy_client(proxy_config)
resp = await client.post(url, json=body, headers=headers, timeout=timeout_seconds)
if not (200 <= resp.status_code < 300):
raise RuntimeError(
f"onboardUser failed: status={resp.status_code} body={resp.text[:200] if resp.text else ''}"
)
data = resp.json()
if not isinstance(data, dict):
raise RuntimeError(f"onboardUser: unexpected response type: {type(data)}")
if data.get("done") is True:
response_data = data.get("response")
if isinstance(response_data, dict):
return extract_project_id(response_data)
return ""
if attempt < max_attempts:
await asyncio.sleep(poll_interval)
raise RuntimeError(f"onboardUser: timeout after {max_attempts} attempts")
async def enrich_project_id(
access_token: str,
proxy_config: dict[str, Any] | None = None,
) -> str | None:
"""Best-effort project_id resolution for GeminiCLI OAuth keys."""
code_assist = await load_code_assist(access_token, proxy_config=proxy_config)
project_id = extract_project_id(code_assist)
if project_id:
return project_id
tier_id = extract_tier_id(code_assist)
if tier_id:
try:
project_id = await onboard_user(
access_token,
tier_id=tier_id,
proxy_config=proxy_config,
)
if project_id:
return project_id
except Exception as exc:
logger.warning("GeminiCLI onboardUser failed: {}", exc)
return None
__all__ = [
"enrich_project_id",
"extract_plan_type",
"extract_project_id",
"extract_tier_id",
"load_code_assist",
"onboard_user",
]

View File

@@ -0,0 +1,25 @@
"""GeminiCLI provider constants."""
from __future__ import annotations
from src.config.settings import config
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
from src.core.provider_templates.types import ProviderType
PROD_BASE_URL = FIXED_PROVIDERS[ProviderType.GEMINI_CLI].api_base_url
V1INTERNAL_PATH_TEMPLATE = "/v1internal:{action}"
def get_v1internal_extra_headers() -> dict[str, str]:
"""Headers required by GeminiCLI upstream requests."""
return {
"Accept-Encoding": "identity",
"User-Agent": config.internal_user_agent_gemini_cli,
}
__all__ = [
"PROD_BASE_URL",
"V1INTERNAL_PATH_TEMPLATE",
"get_v1internal_extra_headers",
]

View File

@@ -0,0 +1,96 @@
"""GeminiCLI v1internal request envelope."""
from __future__ import annotations
from typing import Any
from src.services.provider.adapters.gemini_cli.constants import get_v1internal_extra_headers
from src.services.provider.request_context import get_selected_base_url
def wrap_v1internal_request(
gemini_request: dict[str, Any],
*,
project_id: str,
model: str,
) -> dict[str, Any]:
"""Wrap a Gemini request into GeminiCLI v1internal format."""
inner_request = dict(gemini_request)
inner_request.pop("model", None)
inner_request.pop("stream", None)
return {
"model": model,
"project": project_id,
"request": inner_request,
}
class GeminiCliV1InternalEnvelope:
name = "gemini_cli:v1internal"
def extra_headers(self) -> dict[str, str] | None:
return get_v1internal_extra_headers()
def wrap_request(
self,
request_body: dict[str, Any],
*,
model: str,
url_model: str | None,
decrypted_auth_config: dict[str, Any] | None,
) -> tuple[dict[str, Any], str | None]:
_ = url_model
project_id = (decrypted_auth_config or {}).get("project_id")
if not isinstance(project_id, str) or not project_id:
from src.core.exceptions import ProviderNotAvailableException
raise ProviderNotAvailableException(
"GeminiCLI OAuth 配置缺少 project_id请重新授权",
provider_name="gemini_cli",
upstream_response="missing auth_config.project_id",
)
wrapped = wrap_v1internal_request(
request_body,
project_id=project_id,
model=model,
)
return wrapped, None
def unwrap_response(self, data: Any) -> Any:
if not isinstance(data, dict):
return data
response_obj = data.get("response")
if "candidates" not in data and isinstance(response_obj, dict):
return response_obj
return data
def postprocess_unwrapped_response(self, *, model: str, data: Any) -> None:
_ = model, data
return
def capture_selected_base_url(self) -> str | None:
return get_selected_base_url()
def on_http_status(self, *, base_url: str | None, status_code: int) -> None:
_ = base_url, status_code
return
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None:
_ = base_url, exc
return
def force_stream_rewrite(self) -> bool:
return False
gemini_cli_v1internal_envelope = GeminiCliV1InternalEnvelope()
__all__ = [
"GeminiCliV1InternalEnvelope",
"gemini_cli_v1internal_envelope",
"wrap_v1internal_request",
]

View File

@@ -0,0 +1,158 @@
"""GeminiCLI provider plugin — unified registration entry."""
from __future__ import annotations
import time
from typing import Any
from urllib.parse import urlencode
from src.core.logger import logger
from src.services.provider.adapters.gemini_cli.constants import V1INTERNAL_PATH_TEMPLATE
from src.services.provider.preset_models import get_preset_models
from src.services.provider.request_context import set_selected_base_url
async def fetch_models_gemini_cli(
ctx: Any,
timeout_seconds: float,
) -> tuple[list[dict], list[str], bool, dict[str, Any] | None]:
"""GeminiCLI model fetcher.
Upstream does not expose a stable public models endpoint for OAuth CLI access,
so we currently return a curated preset model catalog and enrich account metadata
from loadCodeAssist when possible.
"""
from src.services.provider.adapters.gemini_cli.client import (
extract_plan_type,
load_code_assist,
)
models = get_preset_models("gemini_cli")
upstream_metadata: dict[str, Any] | None = None
access_token = str(getattr(ctx, "api_key_value", "") or "").strip()
if access_token:
try:
code_assist = await load_code_assist(
access_token,
proxy_config=getattr(ctx, "proxy_config", None),
timeout_seconds=timeout_seconds,
)
provider_meta: dict[str, Any] = {"updated_at": int(time.time())}
plan_type = extract_plan_type(code_assist)
if plan_type:
provider_meta["plan_type"] = plan_type
project_id = (getattr(ctx, "auth_config", None) or {}).get("project_id")
if isinstance(project_id, str) and project_id:
provider_meta["project_id"] = project_id
upstream_metadata = {"gemini_cli": provider_meta}
except Exception as exc:
logger.debug("GeminiCLI model metadata fetch failed: {}", exc)
return models, [], True, upstream_metadata
def build_gemini_cli_url(
endpoint: Any,
*,
is_stream: bool,
effective_query_params: dict[str, Any],
**_kwargs: Any,
) -> str:
"""Build GeminiCLI v1internal URL."""
base_url = str(getattr(endpoint, "base_url", "") or "").rstrip("/")
set_selected_base_url(base_url)
action = "streamGenerateContent" if is_stream else "generateContent"
path = V1INTERNAL_PATH_TEMPLATE.format(action=action)
url = f"{base_url}{path}"
if is_stream:
effective_query_params.setdefault("alt", "sse")
if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
return url
async def enrich_gemini_cli(
auth_config: dict[str, Any],
token_response: dict[str, Any],
access_token: str,
proxy_config: dict[str, Any] | None,
) -> dict[str, Any]:
"""GeminiCLI auth_config enrichment: email + project_id."""
from src.core.provider_oauth_utils import fetch_google_email
from src.services.provider.adapters.gemini_cli.client import (
enrich_project_id,
extract_plan_type,
load_code_assist,
)
if not auth_config.get("email"):
email = await fetch_google_email(
access_token,
proxy_config=proxy_config,
timeout_seconds=10.0,
)
if email:
auth_config["email"] = email
try:
code_assist = await load_code_assist(access_token, proxy_config=proxy_config)
except Exception as exc:
code_assist = None
logger.warning("[enrich] GeminiCLI loadCodeAssist failed: {}", exc)
if code_assist and not auth_config.get("tier"):
plan_type = extract_plan_type(code_assist)
if plan_type:
auth_config["tier"] = plan_type
if not auth_config.get("project_id"):
try:
project_id = (code_assist and code_assist.get("cloudaicompanionProject")) or None
if isinstance(project_id, dict):
project_id = project_id.get("id")
if isinstance(project_id, str) and project_id.strip():
auth_config["project_id"] = project_id.strip()
else:
project_id = await enrich_project_id(access_token, proxy_config=proxy_config)
if project_id:
auth_config["project_id"] = project_id
logger.info("[enrich] GeminiCLI project_id: {}", project_id[:8] + "...")
except Exception as exc:
logger.warning("[enrich] GeminiCLI project_id enrichment failed: {}", exc)
return auth_config
def register_all() -> None:
"""Register all GeminiCLI hooks into shared registries."""
from src.core.provider_oauth_utils import register_auth_enricher
from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
from src.services.provider.adapters.gemini_cli.envelope import gemini_cli_v1internal_envelope
from src.services.provider.envelope import register_envelope
from src.services.provider.transport import register_transport_hook
register_envelope("gemini_cli", "gemini:cli", gemini_cli_v1internal_envelope)
register_envelope("gemini_cli", "gemini:chat", gemini_cli_v1internal_envelope)
register_envelope("gemini_cli", "", gemini_cli_v1internal_envelope)
register_transport_hook("gemini_cli", "gemini:cli", build_gemini_cli_url)
register_transport_hook("gemini_cli", "gemini:chat", build_gemini_cli_url)
register_auth_enricher("gemini_cli", enrich_gemini_cli)
UpstreamModelsFetcherRegistry.register(
provider_types=["gemini_cli"],
fetcher=fetch_models_gemini_cli,
)
__all__ = [
"build_gemini_cli_url",
"enrich_gemini_cli",
"fetch_models_gemini_cli",
"register_all",
]

View File

@@ -0,0 +1,275 @@
"""Gemini CLI quota / RESOURCE_EXHAUSTED helpers."""
from __future__ import annotations
import json
import re
import time
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
_DURATION_TOKEN_RE = re.compile(r"(\d+(?:\.\d+)?)([dhms])")
_RESET_AFTER_RE = re.compile(r"reset after\s+([^.,;]+)", re.IGNORECASE)
def _parse_json(error_text: str | None) -> dict[str, Any] | None:
if not isinstance(error_text, str) or not error_text.strip():
return None
try:
data = json.loads(error_text)
except Exception:
return None
return data if isinstance(data, dict) else None
def _parse_duration_seconds(raw: Any) -> int | None:
if isinstance(raw, (int, float)):
return max(1, int(raw))
if not isinstance(raw, str):
return None
text = raw.strip().lower()
if not text:
return None
total_seconds = 0.0
matched = False
for amount_text, unit in _DURATION_TOKEN_RE.findall(text):
matched = True
amount = float(amount_text)
if unit == "d":
total_seconds += amount * 86400
elif unit == "h":
total_seconds += amount * 3600
elif unit == "m":
total_seconds += amount * 60
elif unit == "s":
total_seconds += amount
if not matched:
return None
return max(1, int(total_seconds))
def _iter_error_details(payload: dict[str, Any]) -> list[dict[str, Any]]:
error_obj = payload.get("error")
if not isinstance(error_obj, dict):
return []
details = error_obj.get("details")
if not isinstance(details, list):
return []
return [item for item in details if isinstance(item, dict)]
def _error_status(payload: dict[str, Any]) -> str:
error_obj = payload.get("error")
if not isinstance(error_obj, dict):
return ""
status = error_obj.get("status")
return status.strip() if isinstance(status, str) else ""
def _error_message(payload: dict[str, Any]) -> str:
error_obj = payload.get("error")
if not isinstance(error_obj, dict):
return ""
message = error_obj.get("message")
return message.strip() if isinstance(message, str) else ""
def _error_reason(payload: dict[str, Any]) -> str:
for detail in _iter_error_details(payload):
reason = detail.get("reason")
if isinstance(reason, str) and reason.strip():
return reason.strip()
return ""
def _looks_like_uuid(value: str | None) -> bool:
if not isinstance(value, str):
return False
text = value.strip()
if not text:
return False
try:
UUID(text)
except Exception:
return False
return True
def is_resource_exhausted_error(error_text: str | None) -> bool:
payload = _parse_json(error_text)
if payload is None:
return False
status = _error_status(payload).upper()
reason = _error_reason(payload).upper()
if status == "RESOURCE_EXHAUSTED" or reason == "QUOTA_EXHAUSTED":
return True
message = _error_message(payload).lower()
return ("exhausted your capacity" in message) or ("quota" in message and "exhaust" in message)
def parse_quota_reset_timestamp(error_text: str | None) -> int | None:
payload = _parse_json(error_text)
if payload is None:
return None
for detail in _iter_error_details(payload):
metadata = detail.get("metadata")
if not isinstance(metadata, dict):
continue
raw = metadata.get("quotaResetTimeStamp") or metadata.get("quotaResetTimestamp")
if not isinstance(raw, str) or not raw.strip():
continue
text = raw.strip()
try:
if text.endswith("Z"):
text = text[:-1] + "+00:00"
parsed = datetime.fromisoformat(text)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return int(parsed.timestamp())
except Exception:
continue
return None
def parse_quota_reset_delay_seconds(error_text: str | None) -> int | None:
payload = _parse_json(error_text)
if payload is None:
return None
for detail in _iter_error_details(payload):
metadata = detail.get("metadata")
if not isinstance(metadata, dict):
continue
delay = metadata.get("quotaResetDelay")
parsed = _parse_duration_seconds(delay)
if parsed is not None:
return parsed
return None
def parse_quota_reset_message_seconds(error_text: str | None) -> int | None:
payload = _parse_json(error_text)
if payload is None:
return None
message = _error_message(payload)
if not message:
return None
matched = _RESET_AFTER_RE.search(message)
if not matched:
return None
return _parse_duration_seconds(matched.group(1))
def extract_error_model_name(error_text: str | None, *, fallback: str | None = None) -> str | None:
payload = _parse_json(error_text)
if payload is not None:
for detail in _iter_error_details(payload):
metadata = detail.get("metadata")
if not isinstance(metadata, dict):
continue
model = metadata.get("model")
if isinstance(model, str) and model.strip():
return model.strip()
fallback_text = str(fallback or "").strip()
if fallback_text and not _looks_like_uuid(fallback_text):
return fallback_text
return None
def extract_quota_cooldown_seconds(
error_text: str | None, *, now_ts: int | None = None
) -> int | None:
now = int(now_ts or time.time())
reset_at = parse_quota_reset_timestamp(error_text)
if reset_at is not None:
return max(1, reset_at - now)
delay = parse_quota_reset_delay_seconds(error_text)
if delay is not None:
return max(1, delay)
message_delay = parse_quota_reset_message_seconds(error_text)
if message_delay is not None:
return max(1, message_delay)
return None
def build_quota_exhausted_metadata(
*,
model_name: str,
error_text: str | None,
current_namespace: dict[str, Any] | None = None,
now_ts: int | None = None,
) -> dict[str, Any] | None:
normalized_model = str(model_name or "").strip()
if not normalized_model:
return None
if not is_resource_exhausted_error(error_text):
return None
now = int(now_ts or time.time())
reset_at = parse_quota_reset_timestamp(error_text)
if reset_at is None:
delay = parse_quota_reset_delay_seconds(error_text)
if delay is not None:
reset_at = now + delay
if reset_at is None:
message_delay = parse_quota_reset_message_seconds(error_text)
if message_delay is not None:
reset_at = now + message_delay
if reset_at is None:
return None
payload = _parse_json(error_text) or {}
namespace = dict(current_namespace) if isinstance(current_namespace, dict) else {}
quota_by_model_raw = namespace.get("quota_by_model")
quota_by_model = dict(quota_by_model_raw) if isinstance(quota_by_model_raw, dict) else {}
model_entry_raw = quota_by_model.get(normalized_model)
model_entry = dict(model_entry_raw) if isinstance(model_entry_raw, dict) else {}
model_entry["is_exhausted"] = True
model_entry["remaining_fraction"] = 0.0
model_entry["used_percent"] = 100.0
model_entry["updated_at"] = now
model_entry["reset_at"] = reset_at
model_entry["reset_time"] = datetime.fromtimestamp(reset_at, timezone.utc).isoformat()
model_entry["reset_seconds"] = max(0, reset_at - now)
reason = _error_reason(payload) or _error_status(payload) or _error_message(payload)
if reason:
model_entry["reason"] = reason
quota_by_model[normalized_model] = model_entry
namespace["quota_by_model"] = quota_by_model
namespace["updated_at"] = now
status = _error_status(payload)
if status:
namespace["last_error_status"] = status
if reason:
namespace["last_error_reason"] = reason
return {"gemini_cli": namespace}
__all__ = [
"extract_error_model_name",
"build_quota_exhausted_metadata",
"extract_quota_cooldown_seconds",
"is_resource_exhausted_error",
"parse_quota_reset_message_seconds",
"parse_quota_reset_delay_seconds",
"parse_quota_reset_timestamp",
]

View File

@@ -0,0 +1,108 @@
"""Shared Rust executor HTTP helper for Gemini CLI side calls."""
from __future__ import annotations
import json
from typing import Any
import httpx
from src.config.settings import config
from src.core.logger import logger
async def execute_gemini_cli_rust_http_request(
*,
method: str,
url: str,
headers: dict[str, str],
body: Any,
proxy_config: dict[str, Any] | None,
request_id: str,
provider_api_format: str,
timeout_seconds: float,
content_type: str | None = None,
) -> httpx.Response | None:
from src.services.request.execution_runtime_plan import (
ExecutionPlan,
ExecutionPlanBody,
ExecutionPlanTimeouts,
build_execution_plan_body,
build_proxy_snapshot,
)
from src.services.request.execution_runtime_client import (
ExecutionRuntimeClient,
ExecutionRuntimeClientError,
)
if config.execution_runtime_backend != "rust":
return None
final_headers = dict(headers)
if (
body is not None
and content_type
and not any(str(key).lower() == "content-type" for key in final_headers)
):
final_headers["content-type"] = content_type
timeout_ms = max(int(timeout_seconds * 1000), 1_000)
try:
proxy_snapshot = await build_proxy_snapshot(proxy_config, label="GeminiCLI")
result = await ExecutionRuntimeClient().execute_sync_json(
ExecutionPlan(
request_id=request_id,
candidate_id=None,
provider_name="gemini_cli",
provider_id="",
endpoint_id="",
key_id="",
method=method,
url=url,
headers=final_headers,
body=(
build_execution_plan_body(body, content_type=content_type)
if body is not None
else ExecutionPlanBody()
),
stream=False,
provider_api_format=provider_api_format,
client_api_format=provider_api_format,
model_name="gemini_cli",
content_type=content_type,
proxy=proxy_snapshot,
timeouts=ExecutionPlanTimeouts(
connect_ms=timeout_ms,
read_ms=timeout_ms,
write_ms=timeout_ms,
pool_ms=timeout_ms,
total_ms=timeout_ms,
),
)
)
except (ExecutionRuntimeClientError, httpx.HTTPError, json.JSONDecodeError) as exc:
logger.warning("GeminiCLI Rust HTTP fallback {} {}: {}", method, url, exc)
return None
except Exception as exc:
logger.warning("GeminiCLI Rust HTTP unexpected fallback {} {}: {}", method, url, exc)
return None
response_headers = dict(result.headers)
if result.response_json is not None:
response_headers.setdefault("content-type", "application/json")
response_body = json.dumps(result.response_json, ensure_ascii=False).encode("utf-8")
elif result.response_body_bytes is not None:
response_body = result.response_body_bytes
else:
response_body = b""
return httpx.Response(
status_code=result.status_code,
request=httpx.Request(method, url, headers=final_headers),
headers=response_headers,
content=response_body,
)
__all__ = ["execute_gemini_cli_rust_http_request"]

View File

@@ -0,0 +1,3 @@
"""Kiro provider adapter."""
__all__ = []

View File

@@ -0,0 +1,95 @@
"""Kiro adapter constants.
Kiro upstream uses AWS Event Stream (binary frames) for streaming responses.
"""
from __future__ import annotations
import platform
AWS_EVENTSTREAM_CONTENT_TYPE = "application/vnd.amazon.eventstream"
# Kiro API endpoints
KIRO_GENERATE_ASSISTANT_PATH = "/generateAssistantResponse"
KIRO_USAGE_LIMITS_PATH = "/getUsageLimits"
# Default AWS region when not specified in credentials
DEFAULT_REGION = "us-east-1"
# Default client fingerprints used in headers (best-effort)
DEFAULT_KIRO_VERSION = "0.8.0"
DEFAULT_NODE_VERSION = "22.21.1"
def _detect_system_version() -> str:
system = platform.system().lower() or "other"
release = platform.release() or "unknown"
# Match KiroIDE style: darwin#24.6.0, windows#10.0.22631, linux#6.8.0-...
return f"{system}#{release}"
DEFAULT_SYSTEM_VERSION = _detect_system_version()
# Header constants
KIRO_AGENT_MODE = "vibe"
CODEWHISPERER_OPTOUT = "true"
# aws-sdk-js versions observed in kiro.rs
AWS_SDK_JS_MAIN_VERSION = "1.0.27"
AWS_SDK_JS_USAGE_VERSION = "1.0.0"
# Claude model context window used by kiro.rs to convert contextUsage percentage -> tokens
CONTEXT_WINDOW_TOKENS = 200_000
# ---------------------------------------------------------------------------
# Chunked-write policy injected into tool descriptions and system prompt
# ---------------------------------------------------------------------------
# Kiro upstream has lower per-message size limits than standard Claude.
# We inject instructions for Write/Edit tools and a system-level policy
# so the model splits large writes into smaller chunks automatically.
WRITE_TOOL_DESCRIPTION_SUFFIX = (
"- IMPORTANT: If the content to write exceeds 150 lines, you MUST only write "
"the first 50 lines using this tool, then use `Edit` tool to append the "
"remaining content in chunks of no more than 50 lines each. If needed, leave "
"a unique placeholder to help append content. Do NOT attempt to write all "
"content at once."
)
EDIT_TOOL_DESCRIPTION_SUFFIX = (
"- IMPORTANT: If the `new_string` content exceeds 50 lines, you MUST split "
"it into multiple Edit calls, each replacing no more than 50 lines at a time. "
"If used to append content, leave a unique placeholder to help append content. "
"On the final chunk, do NOT include the placeholder."
)
TOOL_DESCRIPTION_SUFFIXES: dict[str, str] = {
"Write": WRITE_TOOL_DESCRIPTION_SUFFIX,
"Edit": EDIT_TOOL_DESCRIPTION_SUFFIX,
}
SYSTEM_CHUNKED_POLICY = (
"When the Write or Edit tool has content size limits, always comply silently. "
"Never suggest bypassing these limits via alternative tools. "
"Never ask the user whether to switch approaches. "
"Complete all chunked operations without commentary."
)
__all__ = [
"AWS_EVENTSTREAM_CONTENT_TYPE",
"AWS_SDK_JS_MAIN_VERSION",
"AWS_SDK_JS_USAGE_VERSION",
"CODEWHISPERER_OPTOUT",
"CONTEXT_WINDOW_TOKENS",
"DEFAULT_KIRO_VERSION",
"DEFAULT_NODE_VERSION",
"DEFAULT_REGION",
"DEFAULT_SYSTEM_VERSION",
"EDIT_TOOL_DESCRIPTION_SUFFIX",
"KIRO_AGENT_MODE",
"KIRO_GENERATE_ASSISTANT_PATH",
"KIRO_USAGE_LIMITS_PATH",
"SYSTEM_CHUNKED_POLICY",
"TOOL_DESCRIPTION_SUFFIXES",
"WRITE_TOOL_DESCRIPTION_SUFFIX",
]

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
import contextvars
from dataclasses import dataclass, replace
@dataclass(frozen=True, slots=True)
class KiroRequestContext:
"""Per-request context for the Kiro adapter.
This bridges data from `KiroEnvelope.wrap_request()` (which receives the
decrypted auth_config + original request body) to other layers that only
expose parameterless hooks (extra_headers) or transport hooks.
"""
region: str
machine_id: str
kiro_version: str | None = None
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(
"kiro_request_context",
default=None,
)
def set_kiro_request_context(ctx: KiroRequestContext | None) -> None:
_kiro_request_context.set(ctx)
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",
]

View File

@@ -0,0 +1,613 @@
"""Claude Messages -> Kiro ConversationState converter (best-effort).
This mirrors `kiro.rs/src/anthropic/converter.rs` but focuses on the fields
needed by generateAssistantResponse.
"""
from __future__ import annotations
import json
import uuid
from typing import Any
from src.core.logger import logger
from src.services.provider.adapters.kiro.constants import (
SYSTEM_CHUNKED_POLICY as _SYSTEM_CHUNKED_POLICY,
)
from src.services.provider.adapters.kiro.constants import (
TOOL_DESCRIPTION_SUFFIXES as _TOOL_DESCRIPTION_SUFFIXES,
)
def map_model(model: str) -> str | None:
"""Pass through the model name as-is to Kiro upstream."""
raw = str(model or "").strip()
return raw or None
def _extract_session_id(user_id: str) -> str | None:
text = str(user_id or "")
pos = text.find("session_")
if pos < 0:
return None
session_part = text[pos + 8 :]
if len(session_part) < 36:
return None
candidate = session_part[:36]
if candidate.count("-") != 4:
return None
return candidate
def _generate_thinking_prefix(request_body: dict[str, Any]) -> str | None:
thinking = request_body.get("thinking")
if not isinstance(thinking, dict):
return None
thinking_type = str(thinking.get("type") or "").strip()
if thinking_type == "enabled":
budget = thinking.get("budget_tokens")
try:
budget_i = int(budget) if budget is not None else 0
except Exception:
budget_i = 0
return (
f"<thinking_mode>enabled</thinking_mode>"
f"<max_thinking_length>{budget_i}</max_thinking_length>"
)
if thinking_type == "adaptive":
output_cfg = request_body.get("output_config")
effort = "high"
if isinstance(output_cfg, dict):
eff = output_cfg.get("effort")
if isinstance(eff, str) and eff.strip():
effort = eff.strip()
return (
f"<thinking_mode>adaptive</thinking_mode>"
f"<thinking_effort>{effort}</thinking_effort>"
)
return None
def _has_thinking_tags(content: str) -> bool:
return "<thinking_mode>" in content or "<max_thinking_length>" in content
def _system_to_text(system: Any) -> str:
if system is None:
return ""
if isinstance(system, str):
return system
if isinstance(system, list):
parts: list[str] = []
for item in system:
if isinstance(item, dict):
t = item.get("text")
if isinstance(t, str) and t:
parts.append(t)
else:
# best-effort
try:
parts.append(str(item))
except Exception:
pass
return "\n".join([p for p in parts if p])
return ""
def _get_image_format(media_type: str | None) -> str | None:
if not isinstance(media_type, str) or "/" not in media_type:
return None
prefix, suffix = media_type.split("/", 1)
if prefix != "image":
return None
suffix = suffix.strip().lower()
if suffix in {"jpeg", "png", "gif", "webp"}:
return suffix
if suffix == "jpg":
return "jpeg"
return None
def _process_message_content(
content: Any,
) -> tuple[str, list[dict[str, Any]], list[dict[str, Any]]]:
"""Extract text/images/tool_results from a Claude content field."""
text_parts: list[str] = []
images: list[dict[str, Any]] = []
tool_results: list[dict[str, Any]] = []
if isinstance(content, str):
if content:
text_parts.append(content)
return "".join(text_parts), images, tool_results
if not isinstance(content, list):
return "".join(text_parts), images, tool_results
for block in content:
if not isinstance(block, dict):
continue
btype = str(block.get("type") or "").strip()
if btype == "text":
text = block.get("text")
if isinstance(text, str) and text:
text_parts.append(text)
continue
if btype == "image":
source = block.get("source")
if not isinstance(source, dict):
continue
media_type = source.get("media_type") or source.get("mediaType")
fmt = _get_image_format(media_type if isinstance(media_type, str) else None)
data = source.get("data")
if fmt and isinstance(data, str) and data:
images.append({"format": fmt, "source": {"bytes": data}})
continue
if btype == "tool_result":
tool_use_id = block.get("tool_use_id") or block.get("toolUseId")
if not isinstance(tool_use_id, str) or not tool_use_id.strip():
continue
raw_content = block.get("content")
if isinstance(raw_content, str):
text = raw_content
elif isinstance(raw_content, list):
# Claude tool_result content blocks; keep only text parts.
parts: list[str] = []
for item in raw_content:
if isinstance(item, dict) and item.get("type") == "text":
t = item.get("text")
if isinstance(t, str) and t:
parts.append(t)
text = "\n".join(parts)
else:
try:
text = json.dumps(raw_content, ensure_ascii=False)
except Exception:
text = str(raw_content)
is_error = bool(block.get("is_error") or block.get("isError") or False)
status = "error" if is_error else "success"
tool_results.append(
{
"toolUseId": tool_use_id.strip(),
"content": [{"text": text or ""}],
"status": status,
"isError": bool(is_error),
}
)
continue
return "".join(text_parts), images, tool_results
def _clean_tool_schema(schema: dict[str, Any]) -> dict[str, Any]:
"""Recursively remove fields that Kiro API rejects.
Kiro returns 400 "Improperly formed request" when tool schemas contain
``additionalProperties`` (any value) or empty ``required: []`` arrays.
"""
if not isinstance(schema, dict):
return schema # type: ignore[return-value]
result: dict[str, Any] = {}
for key, value in schema.items():
if key == "additionalProperties":
continue
if key == "required" and isinstance(value, list) and not value:
continue
if isinstance(value, dict):
result[key] = _clean_tool_schema(value)
elif isinstance(value, list):
result[key] = [_clean_tool_schema(v) if isinstance(v, dict) else v for v in value]
else:
result[key] = value
return result
def _convert_tools(tools: Any) -> list[dict[str, Any]]:
if not isinstance(tools, list):
return []
out: list[dict[str, Any]] = []
for t in tools:
if not isinstance(t, dict):
continue
name = t.get("name")
if not isinstance(name, str) or not name.strip():
continue
description = t.get("description")
description_str = description if isinstance(description, str) else ""
# Inject chunked-write instructions for Write/Edit tools.
suffix = _TOOL_DESCRIPTION_SUFFIXES.get(name.strip())
if suffix:
description_str = f"{description_str}\n{suffix}" if description_str else suffix
if len(description_str) > 10000:
description_str = description_str[:10000]
input_schema = t.get("input_schema") or t.get("inputSchema") or {}
if not isinstance(input_schema, dict):
input_schema = {}
input_schema = _clean_tool_schema(input_schema)
out.append(
{
"toolSpecification": {
"name": name.strip(),
"description": description_str,
"inputSchema": {"json": input_schema},
}
}
)
return out
def _create_placeholder_tool(name: str) -> dict[str, Any]:
return {
"toolSpecification": {
"name": name,
"description": "Tool used in conversation history",
"inputSchema": {
"json": {
"type": "object",
"properties": {},
}
},
}
}
def _convert_assistant_message(message: dict[str, Any]) -> dict[str, Any] | None:
content = message.get("content")
tool_uses: list[dict[str, Any]] = []
thinking_parts: list[str] = []
text_parts: list[str] = []
if isinstance(content, str):
if content:
text_parts.append(content)
elif isinstance(content, list):
for block in content:
if not isinstance(block, dict):
continue
btype = str(block.get("type") or "")
if btype == "thinking":
# Preserve thinking content so multi-turn context is not lost.
t = block.get("thinking")
if isinstance(t, str) and t:
thinking_parts.append(t)
elif btype == "text":
t = block.get("text")
if isinstance(t, str) and t:
text_parts.append(t)
elif btype == "tool_use":
tool_use_id = block.get("id")
name = block.get("name")
if not isinstance(tool_use_id, str) or not tool_use_id.strip():
continue
if not isinstance(name, str) or not name.strip():
continue
inp = block.get("input")
if not isinstance(inp, dict):
inp = {}
tool_uses.append(
{
"toolUseId": tool_use_id.strip(),
"name": name.strip(),
"input": inp,
}
)
# Combine thinking + text into final content.
# Format: <thinking>...</thinking>\n\ntext
thinking_str = "".join(thinking_parts)
text_str = "".join(text_parts)
if thinking_str:
if text_str:
content_str = f"<thinking>{thinking_str}</thinking>\n\n{text_str}"
else:
content_str = f"<thinking>{thinking_str}</thinking>"
else:
content_str = text_str
if not content_str and tool_uses:
content_str = " " # Kiro API requires non-empty content.
if not content_str and not tool_uses:
return None
out: dict[str, Any] = {"content": content_str}
if tool_uses:
out["toolUses"] = tool_uses
return out
def convert_claude_messages_to_conversation_state(
request_body: dict[str, Any],
*,
model: str,
) -> dict[str, Any]:
model_id = map_model(model)
if not model_id:
raise ValueError(f"kiro: model is required (got {model!r})")
messages = request_body.get("messages")
if not isinstance(messages, list) or not messages:
raise ValueError("kiro: empty messages")
conversation_id = None
metadata = request_body.get("metadata")
if isinstance(metadata, dict):
user_id = metadata.get("user_id") or metadata.get("userId")
if isinstance(user_id, str) and user_id:
conversation_id = _extract_session_id(user_id)
if not conversation_id:
conversation_id = str(uuid.uuid4())
agent_continuation_id = str(uuid.uuid4())
thinking_prefix = _generate_thinking_prefix(request_body)
history: list[dict[str, Any]] = []
# System injection: add as (user, assistant) pair.
system_text = _system_to_text(request_body.get("system"))
if system_text:
# Append chunked-write policy so the model silently obeys tool limits.
final_system = f"{system_text}\n{_SYSTEM_CHUNKED_POLICY}"
history.append(
{
"userInputMessage": {
"content": final_system,
"modelId": model_id,
"origin": "AI_EDITOR",
}
}
)
history.append(
{"assistantResponseMessage": {"content": "I will follow these instructions."}}
)
# Build history from messages.
# If the last message is assistant, include it in history (Kiro currentMessage
# must be user; we synthesise one). Otherwise the last user message becomes
# currentMessage and everything before it goes into history.
last_msg = messages[-1]
last_is_assistant = (
isinstance(last_msg, dict) and str(last_msg.get("role") or "") == "assistant"
)
if last_is_assistant:
# All messages go into history; we'll synthesise a currentMessage later.
history_end_index = len(messages)
else:
history_end_index = max(len(messages) - 1, 0)
user_buffer: list[dict[str, Any]] = []
def _flush_user_buffer() -> dict[str, Any] | None:
nonlocal user_buffer
if not user_buffer:
return None
parts: list[str] = []
images: list[dict[str, Any]] = []
tool_results: list[dict[str, Any]] = []
for msg in user_buffer:
text, imgs, results = _process_message_content(msg.get("content"))
if text:
parts.append(text)
images.extend(imgs)
tool_results.extend(results)
user_buffer = []
payload: dict[str, Any] = {
"content": "\n".join(parts),
"modelId": model_id,
"origin": "AI_EDITOR",
}
if images:
payload["images"] = images
if tool_results:
payload["userInputMessageContext"] = {"toolResults": tool_results}
return {"userInputMessage": payload}
for i in range(history_end_index):
msg = messages[i]
if not isinstance(msg, dict):
continue
role = str(msg.get("role") or "")
if role == "user":
user_buffer.append(msg)
continue
if role == "assistant":
user_item = _flush_user_buffer()
if user_item is not None:
history.append(user_item)
elif not history or "assistantResponseMessage" in history[-1]:
# No preceding user message: insert synthetic user message
# to maintain alternating roles required by Kiro API.
history.append(
{
"userInputMessage": {
"content": "Continue.",
"modelId": model_id,
"origin": "AI_EDITOR",
}
}
)
assistant_item = _convert_assistant_message(msg)
if assistant_item is not None:
history.append({"assistantResponseMessage": assistant_item})
continue
# trailing unpaired user messages in history
tail_user = _flush_user_buffer()
if tail_user is not None:
history.append(tail_user)
history.append({"assistantResponseMessage": {"content": "OK"}})
# Current message: last message as user input.
if last_is_assistant:
# Synthesise a minimal user continuation message.
text_content = "Continue."
images: list[dict[str, Any]] = []
tool_results: list[dict[str, Any]] = []
else:
last = messages[-1]
if not isinstance(last, dict) or str(last.get("role") or "") != "user":
raise ValueError("kiro: last message must be user")
text_content, images, tool_results = _process_message_content(last.get("content"))
tools = _convert_tools(request_body.get("tools"))
# Ensure tools referenced in history assistant toolUses are defined.
# Also collect ids for tool_use / tool_result pairing validation.
history_tool_names: set[str] = set()
history_tool_results_ids: set[str] = set()
history_tool_use_ids: set[str] = set()
for item in history:
if not isinstance(item, dict):
continue
u = item.get("userInputMessage")
if isinstance(u, dict):
ctx = u.get("userInputMessageContext")
if isinstance(ctx, dict):
results = ctx.get("toolResults")
if isinstance(results, list):
for r in results:
if isinstance(r, dict):
tid = r.get("toolUseId")
if isinstance(tid, str) and tid:
history_tool_results_ids.add(tid)
a = item.get("assistantResponseMessage")
if isinstance(a, dict):
uses = a.get("toolUses")
if isinstance(uses, list):
for tu in uses:
if not isinstance(tu, dict):
continue
nm = tu.get("name")
if isinstance(nm, str) and nm:
history_tool_names.add(nm)
tid = tu.get("toolUseId")
if isinstance(tid, str) and tid:
history_tool_use_ids.add(tid)
existing_tool_names = {
str(t.get("toolSpecification", {}).get("name", "")).lower() for t in tools
}
for tool_name in sorted(history_tool_names):
if tool_name.lower() not in existing_tool_names:
tools.append(_create_placeholder_tool(tool_name))
# Filter tool_results: only keep those with matching tool_use in history, and not duplicated.
validated_tool_results: list[dict[str, Any]] = []
current_tool_result_ids: set[str] = set()
for r in tool_results:
if not isinstance(r, dict):
continue
tid = r.get("toolUseId")
if not isinstance(tid, str) or not tid:
continue
if tid not in history_tool_use_ids:
continue
if tid in history_tool_results_ids:
continue
validated_tool_results.append(r)
current_tool_result_ids.add(tid)
# Remove orphaned tool_uses from history.
# Kiro API requires every tool_use to have a matching tool_result; otherwise
# it returns 400 Bad Request.
orphaned_tool_use_ids = (
history_tool_use_ids - history_tool_results_ids - current_tool_result_ids
)
if orphaned_tool_use_ids:
logger.warning(
"kiro: removing {} orphaned tool_use(s) from history: {}",
len(orphaned_tool_use_ids),
orphaned_tool_use_ids,
)
for item in history:
if not isinstance(item, dict):
continue
a = item.get("assistantResponseMessage")
if not isinstance(a, dict):
continue
uses = a.get("toolUses")
if not isinstance(uses, list):
continue
filtered = [
u
for u in uses
if not (
isinstance(u, dict)
and isinstance(u.get("toolUseId"), str)
and u["toolUseId"] in orphaned_tool_use_ids
)
]
if not filtered:
a.pop("toolUses", None)
elif len(filtered) != len(uses):
a["toolUses"] = filtered
user_ctx: dict[str, Any] = {}
if tools:
user_ctx["tools"] = tools
if validated_tool_results:
user_ctx["toolResults"] = validated_tool_results
# Inject thinking tags into currentMessage (not history) so the
# instruction applies to the current turn only.
if thinking_prefix and not _has_thinking_tags(text_content):
text_content = f"{thinking_prefix}\n{text_content}"
user_input: dict[str, Any] = {
"userInputMessageContext": user_ctx,
"content": text_content,
"modelId": model_id,
"origin": "AI_EDITOR",
}
if images:
user_input["images"] = images
conversation_state = {
"agentContinuationId": agent_continuation_id,
"agentTaskType": "vibe",
"chatTriggerType": "MANUAL",
"currentMessage": {"userInputMessage": user_input},
"conversationId": conversation_id,
"history": history,
}
return conversation_state
__all__ = [
"convert_claude_messages_to_conversation_state",
"map_model",
]

View File

@@ -0,0 +1,121 @@
"""Kiro provider envelope.
Kiro upstream is not Claude wire-compatible:
- Request: wrap Claude Messages body into Kiro `conversationState` request.
- Stream response: handled by StreamProcessor via binary EventStream rewrite.
We use contextvars to pass request-scoped values (region, machine_id, thinking)
from wrap_request() to extra_headers() and transport hook.
"""
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.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.request import (
build_kiro_request_context,
build_kiro_request_payload,
)
from src.services.provider.request_context import get_selected_base_url
class KiroEnvelope:
name = "kiro:generateAssistantResponse"
def extra_headers(self) -> dict[str, str] | None:
# Called after wrap_request(); relies on KiroRequestContext.
from src.services.provider.adapters.kiro.context import get_kiro_request_context
ctx = get_kiro_request_context()
if ctx is None:
return None
host = f"q.{ctx.region}.amazonaws.com"
return build_generate_assistant_headers(
host=host,
machine_id=ctx.machine_id,
kiro_version=ctx.kiro_version,
system_version=ctx.system_version,
node_version=ctx.node_version,
)
def wrap_request(
self,
request_body: dict[str, Any],
*,
model: str,
url_model: str | None,
decrypted_auth_config: dict[str, Any] | None,
) -> tuple[dict[str, Any], str | None]:
cfg = KiroAuthConfig.from_dict(decrypted_auth_config or {})
set_kiro_request_context(build_kiro_request_context(request_body, cfg=cfg))
wrapped = build_kiro_request_payload(
request_body,
model=model,
cfg=cfg,
)
return wrapped, url_model
def unwrap_response(self, data: Any) -> Any:
return data
def postprocess_unwrapped_response(self, *, model: str, data: Any) -> None: # noqa: ARG002
return
def capture_selected_base_url(self) -> str | None:
return get_selected_base_url()
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
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()
__all__ = ["KiroEnvelope", "kiro_envelope"]

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

@@ -0,0 +1,763 @@
"""AWS Event Stream -> Claude SSE rewriter for Kiro.
Kiro streaming responses are returned as `application/vnd.amazon.eventstream`
(binary framed). This module decodes frames and emits Claude-style streaming
SSE events (as UTF-8 bytes).
The output format uses ``event: {type}\\ndata: {...}\\n\\n`` for typed events and
plain ``data: {...}\\n\\n`` for untyped events, matching how Aether parses Claude
streams.
"""
from __future__ import annotations
import json
import uuid
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
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
# pathological upstream responses that never close the thinking tag.
_MAX_THINKING_BUFFER = 1024 * 1024 # 1 MiB
_QUOTE_CHARS: frozenset[str] = frozenset("`\"'\\#!@$%^&*()-_=+[]{};:<>,.?/")
def _is_quote_char(buffer: str, pos: int) -> bool:
if pos < 0 or pos >= len(buffer):
return False
return buffer[pos] in _QUOTE_CHARS
def _find_real_thinking_start_tag(buffer: str) -> int | None:
tag = "<thinking>"
search = 0
while True:
pos = buffer.find(tag, search)
if pos < 0:
return None
has_before = pos > 0 and _is_quote_char(buffer, pos - 1)
after_pos = pos + len(tag)
has_after = _is_quote_char(buffer, after_pos)
if not has_before and not has_after:
return pos
search = pos + 1
def _find_real_thinking_end_tag(buffer: str) -> int | None:
tag = "</thinking>"
search = 0
while True:
pos = buffer.find(tag, search)
if pos < 0:
return None
has_before = pos > 0 and _is_quote_char(buffer, pos - 1)
after_pos = pos + len(tag)
has_after = _is_quote_char(buffer, after_pos)
if has_before or has_after:
search = pos + 1
continue
after = buffer[after_pos:]
if len(after) < 2:
return None
if after.startswith("\n\n"):
return pos
search = pos + 1
def _find_real_thinking_end_tag_at_buffer_end(buffer: str) -> int | None:
tag = "</thinking>"
search = 0
while True:
pos = buffer.find(tag, search)
if pos < 0:
return None
has_before = pos > 0 and _is_quote_char(buffer, pos - 1)
after_pos = pos + len(tag)
has_after = _is_quote_char(buffer, after_pos)
if has_before or has_after:
search = pos + 1
continue
if buffer[after_pos:].strip() == "":
return pos
search = pos + 1
def _estimate_tokens(text: str) -> int:
if not text:
return 0
chinese = 0
other = 0
for c in text:
if "\u4e00" <= c <= "\u9fff":
chinese += 1
else:
other += 1
chinese_tokens = (chinese * 2 + 2) // 3
other_tokens = (other + 3) // 4
return max(chinese_tokens + other_tokens, 1)
def _sse_data_bytes(obj: dict[str, Any]) -> bytes:
data = json.dumps(obj, ensure_ascii=False)
event_type = obj.get("type", "")
if event_type:
return f"event: {event_type}\ndata: {data}\n\n".encode("utf-8")
return f"data: {data}\n\n".encode("utf-8")
@dataclass(slots=True)
class _KiroStreamState:
model: str
thinking_enabled: bool
estimated_input_tokens: int = 0
message_id: str = field(default_factory=lambda: f"msg_{uuid.uuid4().hex}")
output_tokens: int = 0
context_input_tokens: int | None = None
next_block_index: int = 0
open_blocks: dict[int, str] = field(default_factory=dict)
text_block_index: int | None = None
thinking_block_index: int | None = None
tool_block_indices: dict[str, int] = field(default_factory=dict)
thinking_buffer: str = ""
in_thinking_block: bool = False
thinking_extracted: bool = False
strip_thinking_leading_newline: bool = False
has_tool_use: bool = False
stop_reason_override: str | None = None
had_error: bool = False
_last_content: str = ""
def generate_initial_events(self) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
# message_start
events.append(
{
"type": "message_start",
"message": {
"id": self.message_id,
"type": "message",
"role": "assistant",
"content": [],
"model": self.model,
"stop_reason": None,
"stop_sequence": None,
# Claude CLI clients expect usage to exist.
"usage": {
"input_tokens": int(self.estimated_input_tokens or 0),
"output_tokens": 1,
},
},
}
)
if not self.thinking_enabled:
events.extend(self._ensure_text_block_open())
return events
def _ensure_text_block_open(self) -> list[dict[str, Any]]:
if self.text_block_index is not None:
if (
self.text_block_index in self.open_blocks
and self.open_blocks[self.text_block_index] == "text"
):
return []
self.text_block_index = None
idx = self.next_block_index
self.next_block_index += 1
self.text_block_index = idx
self.open_blocks[idx] = "text"
return [
{
"type": "content_block_start",
"index": idx,
"content_block": {"type": "text", "text": ""},
}
]
def _close_block(self, idx: int) -> list[dict[str, Any]]:
if idx not in self.open_blocks:
return []
self.open_blocks.pop(idx, None)
return [{"type": "content_block_stop", "index": idx}]
def _ensure_thinking_block_open(self) -> list[dict[str, Any]]:
if self.thinking_block_index is not None:
if (
self.thinking_block_index in self.open_blocks
and self.open_blocks[self.thinking_block_index] == "thinking"
):
return []
idx = self.next_block_index
self.next_block_index += 1
self.thinking_block_index = idx
self.open_blocks[idx] = "thinking"
return [
{
"type": "content_block_start",
"index": idx,
"content_block": {"type": "thinking", "thinking": ""},
}
]
def _emit_text_delta(self, text: str) -> list[dict[str, Any]]:
if not text:
return []
events: list[dict[str, Any]] = []
events.extend(self._ensure_text_block_open())
idx = int(self.text_block_index or 0)
events.append(
{
"type": "content_block_delta",
"index": idx,
"delta": {"type": "text_delta", "text": text},
}
)
return events
def _emit_thinking_delta(self, thinking: str) -> list[dict[str, Any]]:
if not thinking:
return []
events: list[dict[str, Any]] = []
events.extend(self._ensure_thinking_block_open())
idx = int(self.thinking_block_index or 0)
events.append(
{
"type": "content_block_delta",
"index": idx,
"delta": {"type": "thinking_delta", "thinking": thinking},
}
)
return events
def _close_thinking_block(self) -> list[dict[str, Any]]:
"""Send an empty thinking_delta sentinel and close the thinking block."""
if self.thinking_block_index is None:
return []
idx = int(self.thinking_block_index)
events: list[dict[str, Any]] = [
{
"type": "content_block_delta",
"index": idx,
"delta": {"type": "thinking_delta", "thinking": ""},
}
]
events.extend(self._close_block(idx))
return events
def process_context_usage(self, percentage: float) -> None:
try:
pct = float(percentage)
except Exception:
return
# percentage * CONTEXT_WINDOW_TOKENS / 100
self.context_input_tokens = int(pct * float(CONTEXT_WINDOW_TOKENS) / 100.0)
def process_exception(self, exception_type: str) -> None:
if exception_type == "ContentLengthExceededException":
# ContentLengthExceededException is a normal completion signal (output
# exceeded size limit), not a fatal error. We record the stop_reason
# but do NOT set had_error so that finalize() still emits message_delta
# with stop_reason="max_tokens" and message_stop.
self.stop_reason_override = "max_tokens"
return
def process_assistant_response(self, content: str) -> list[dict[str, Any]]:
if not content:
return []
# Kiro may send duplicate content events; skip exact repeats.
if content == self._last_content:
return []
self._last_content = content
self.output_tokens += _estimate_tokens(content)
if not self.thinking_enabled:
return self._emit_text_delta(content)
self.thinking_buffer += content
# Safety: flush as text if thinking_buffer grows too large without closing tag
if len(self.thinking_buffer) > _MAX_THINKING_BUFFER:
logger.warning(
"kiro thinking_buffer exceeded {} bytes, force-flushing as text",
_MAX_THINKING_BUFFER,
)
overflow = self.thinking_buffer
self.thinking_buffer = ""
if self.in_thinking_block:
result = self._emit_thinking_delta(overflow)
result.extend(self._close_thinking_block())
self.in_thinking_block = False
self.thinking_extracted = True
return result
return self._emit_text_delta(overflow)
events: list[dict[str, Any]] = []
while True:
if not self.in_thinking_block and not self.thinking_extracted:
start_pos = _find_real_thinking_start_tag(self.thinking_buffer)
if start_pos is not None:
before = self.thinking_buffer[:start_pos]
if before and before.strip():
events.extend(self._emit_text_delta(before))
self.in_thinking_block = True
self.strip_thinking_leading_newline = True
self.thinking_buffer = self.thinking_buffer[start_pos + len("<thinking>") :]
events.extend(self._ensure_thinking_block_open())
continue
# Keep a short suffix in buffer for partial tag detection.
keep = len("<thinking>")
if len(self.thinking_buffer) > keep:
safe = self.thinking_buffer[:-keep]
if safe and safe.strip():
events.extend(self._emit_text_delta(safe))
self.thinking_buffer = self.thinking_buffer[-keep:]
break
if self.in_thinking_block:
# Strip a single leading \n after <thinking> tag.
# The model outputs `<thinking>\n` and the \n may arrive in the
# same chunk or the next one; we drop it for cleaner output.
if self.strip_thinking_leading_newline:
if self.thinking_buffer.startswith("\n"):
self.thinking_buffer = self.thinking_buffer[1:]
self.strip_thinking_leading_newline = False
elif self.thinking_buffer:
# Buffer is non-empty but doesn't start with \n; stop waiting.
self.strip_thinking_leading_newline = False
# else: buffer is empty, keep the flag for the next chunk.
end_pos = _find_real_thinking_end_tag(self.thinking_buffer)
if end_pos is not None:
thinking_text = self.thinking_buffer[:end_pos]
if thinking_text:
events.extend(self._emit_thinking_delta(thinking_text))
events.extend(self._close_thinking_block())
self.in_thinking_block = False
self.thinking_extracted = True
self.thinking_buffer = self.thinking_buffer[end_pos + len("</thinking>") :]
continue
keep = len("</thinking>")
if len(self.thinking_buffer) > keep:
safe = self.thinking_buffer[:-keep]
if safe:
events.extend(self._emit_thinking_delta(safe))
self.thinking_buffer = self.thinking_buffer[-keep:]
break
# thinking extracted: remaining buffer is text
if self.thinking_buffer:
remaining = self.thinking_buffer
self.thinking_buffer = ""
events.extend(self._emit_text_delta(remaining))
break
return events
def process_tool_use(
self,
*,
name: str,
tool_use_id: str,
input_json: str,
stop: bool,
) -> list[dict[str, Any]]:
if not tool_use_id:
return []
self.has_tool_use = True
events: list[dict[str, Any]] = []
# Boundary: close thinking block if needed, filtering a dangling </thinking>.
if self.thinking_enabled and self.in_thinking_block and self.thinking_buffer:
end_pos = _find_real_thinking_end_tag_at_buffer_end(self.thinking_buffer)
if end_pos is not None:
thinking_text = self.thinking_buffer[:end_pos]
if thinking_text:
events.extend(self._emit_thinking_delta(thinking_text))
events.extend(self._close_thinking_block())
after_pos = end_pos + len("</thinking>")
remaining = self.thinking_buffer[after_pos:]
self.thinking_buffer = ""
self.in_thinking_block = False
self.thinking_extracted = True
if remaining:
events.extend(self._emit_text_delta(remaining))
else:
# Best-effort flush all as thinking
events.extend(self._emit_thinking_delta(self.thinking_buffer))
events.extend(self._close_thinking_block())
self.thinking_buffer = ""
self.in_thinking_block = False
self.thinking_extracted = True
# Flush any buffered pre-thinking tail so tool_use doesn't swallow it.
if (
self.thinking_enabled
and not self.in_thinking_block
and not self.thinking_extracted
and self.thinking_buffer
):
buffered = self.thinking_buffer
self.thinking_buffer = ""
events.extend(self._emit_text_delta(buffered))
# Close current text block before tool_use.
if self.text_block_index is not None:
idx = int(self.text_block_index)
events.extend(self._close_block(idx))
block_index = self.tool_block_indices.get(tool_use_id)
if block_index is None:
block_index = self.next_block_index
self.next_block_index += 1
self.tool_block_indices[tool_use_id] = block_index
# Start tool block if not open.
if block_index not in self.open_blocks:
self.open_blocks[block_index] = "tool_use"
events.append(
{
"type": "content_block_start",
"index": block_index,
"content_block": {
"type": "tool_use",
"id": tool_use_id,
"name": name,
"input": {},
},
}
)
if input_json:
self.output_tokens += _estimate_tokens(input_json)
events.append(
{
"type": "content_block_delta",
"index": block_index,
"delta": {"type": "input_json_delta", "partial_json": input_json},
}
)
if stop:
events.extend(self._close_block(block_index))
return events
def finalize(self) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
# Flush remaining thinking/text buffer.
if self.thinking_enabled and self.thinking_buffer:
if self.in_thinking_block:
end_pos = _find_real_thinking_end_tag_at_buffer_end(self.thinking_buffer)
if end_pos is not None:
thinking_text = self.thinking_buffer[:end_pos]
if thinking_text:
events.extend(self._emit_thinking_delta(thinking_text))
events.extend(self._close_thinking_block())
after_pos = end_pos + len("</thinking>")
remaining = self.thinking_buffer[after_pos:]
if remaining:
events.extend(self._emit_text_delta(remaining))
else:
events.extend(self._emit_thinking_delta(self.thinking_buffer))
events.extend(self._close_thinking_block())
else:
events.extend(self._emit_text_delta(self.thinking_buffer))
self.thinking_buffer = ""
self.in_thinking_block = False
self.thinking_extracted = True
# Close any open blocks (best-effort).
for idx in sorted(list(self.open_blocks.keys()), reverse=True):
events.extend(self._close_block(idx))
stop_reason = self.stop_reason_override
if not stop_reason:
stop_reason = "tool_use" if self.has_tool_use else "end_turn"
input_tokens = (
int(self.context_input_tokens)
if self.context_input_tokens is not None
else int(self.estimated_input_tokens or 0)
)
events.append(
{
"type": "message_delta",
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
"usage": {"input_tokens": input_tokens, "output_tokens": int(self.output_tokens)},
}
)
events.append({"type": "message_stop"})
return events
async def rewrite_eventstream_to_sse(
byte_iterator: Any,
*,
model: str,
thinking_enabled: bool,
estimated_input_tokens: int = 0,
) -> AsyncGenerator[bytes]:
"""Rewrite Kiro AWS Event Stream bytes to Claude SSE bytes."""
decoder = EventStreamDecoder()
state = _KiroStreamState(
model=str(model or ""),
thinking_enabled=bool(thinking_enabled),
estimated_input_tokens=int(estimated_input_tokens or 0),
)
# 收集原始字节用于错误诊断
raw_bytes_buffer = b""
# Initial events
for evt in state.generate_initial_events():
yield _sse_data_bytes(evt)
async for chunk in byte_iterator:
if not chunk:
continue
# 保留原始字节用于错误诊断(限制大小)
if len(raw_bytes_buffer) < 4096:
raw_bytes_buffer += chunk
try:
decoder.feed(chunk)
frames = decoder.decode_available()
except Exception as e:
logger.warning("kiro eventstream decode error: {}", e)
# 尝试解析原始响应为 JSON 错误
error_message = f"kiro eventstream decode failed: {type(e).__name__}"
try:
raw_text = raw_bytes_buffer.decode("utf-8", errors="replace")
# 尝试解析为 JSON
error_json = json.loads(raw_text)
if isinstance(error_json, dict):
# 提取上游错误信息
upstream_msg = error_json.get("message") or error_json.get("error", {}).get(
"message"
)
if upstream_msg:
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",
"error": {
"type": "upstream_stream_error",
"message": error_message,
},
}
)
break
for frame in frames:
mtype = (frame.message_type() or "event").strip().lower()
etype = (frame.event_type() or "").strip()
payload_text = frame.payload_as_text()
if mtype == "event":
try:
payload = json.loads(payload_text) if payload_text else {}
except Exception:
payload = {}
if etype == "assistantResponseEvent":
content = payload.get("content") if isinstance(payload, dict) else None
if isinstance(content, str) and content:
for evt in state.process_assistant_response(content):
yield _sse_data_bytes(evt)
continue
if etype == "toolUseEvent":
if isinstance(payload, dict):
name = str(payload.get("name") or "")
tool_use_id = payload.get("toolUseId") or payload.get("tool_use_id")
tool_use_id = str(tool_use_id or "")
raw_input = payload.get("input")
if raw_input is None:
input_json = ""
elif isinstance(raw_input, str):
input_json = raw_input
else:
try:
input_json = json.dumps(raw_input, ensure_ascii=False)
except Exception:
input_json = str(raw_input)
stop = bool(payload.get("stop", False))
for evt in state.process_tool_use(
name=name,
tool_use_id=tool_use_id,
input_json=input_json,
stop=stop,
):
yield _sse_data_bytes(evt)
continue
if etype == "contextUsageEvent":
if isinstance(payload, dict):
pct = payload.get("contextUsagePercentage")
if pct is not None:
try:
state.process_context_usage(float(pct))
except (ValueError, TypeError):
logger.debug(
"kiro: failed to parse contextUsagePercentage: {!r}", pct
)
continue
# meteringEvent / unknown: ignore
continue
if mtype == "exception":
ex_type = frame.headers.exception_type() or "UnknownException"
state.process_exception(ex_type)
# ContentLengthExceededException is handled by process_exception
# (sets stop_reason_override) and should NOT prevent finalize().
if not state.stop_reason_override:
state.had_error = True
logger.debug("kiro upstream exception: {} | {}", ex_type, payload_text[:200])
if state.had_error:
yield _sse_data_bytes(
{
"type": "error",
"error": {
"type": "upstream_exception",
"message": ex_type,
},
}
)
continue
if mtype == "error":
err_code = frame.headers.error_code() or "UnknownError"
state.had_error = True
logger.debug("kiro upstream error: {} | {}", err_code, payload_text[:200])
yield _sse_data_bytes(
{
"type": "error",
"error": {
"type": "upstream_error",
"message": err_code,
},
}
)
continue
if not state.had_error:
for evt in state.finalize():
yield _sse_data_bytes(evt)
def apply_kiro_stream_rewrite(
byte_iter: Any,
*,
model: str = "",
input_tokens: int = 0,
prefetched_chunks: list[bytes] | None = None,
) -> AsyncGenerator[bytes]:
"""Apply Kiro EventStream->SSE rewrite if context is available.
Consolidates the repeated import-context-rewrite pattern used across
``chat_handler_base``, ``cli_handler_base``, and ``stream_processor``.
Args:
byte_iter: Upstream byte iterator (raw AWS Event Stream).
model: Model name for SSE events.
input_tokens: Estimated input token count.
prefetched_chunks: Optional pre-fetched bytes to prepend.
Returns:
An async generator of Claude-compatible SSE bytes.
"""
from src.services.provider.adapters.kiro.context import get_kiro_request_context
kiro_ctx = get_kiro_request_context()
thinking_enabled = bool(getattr(kiro_ctx, "thinking_enabled", False)) if kiro_ctx else False
if prefetched_chunks:
upstream = byte_iter
prefix = list(prefetched_chunks)
async def _combined() -> AsyncGenerator[bytes, None]:
for c in prefix:
if c:
yield c
async for c in upstream:
if c:
yield c
source: Any = _combined()
else:
source = byte_iter
return rewrite_eventstream_to_sse(
source,
model=str(model or ""),
thinking_enabled=thinking_enabled,
estimated_input_tokens=int(input_tokens or 0),
)
__all__ = [
"apply_kiro_stream_rewrite",
"rewrite_eventstream_to_sse",
]

View File

@@ -0,0 +1,102 @@
"""Kiro header builders."""
from __future__ import annotations
import uuid
from src.services.provider.adapters.kiro.constants import (
AWS_EVENTSTREAM_CONTENT_TYPE,
AWS_SDK_JS_MAIN_VERSION,
AWS_SDK_JS_USAGE_VERSION,
CODEWHISPERER_OPTOUT,
DEFAULT_KIRO_VERSION,
DEFAULT_NODE_VERSION,
DEFAULT_SYSTEM_VERSION,
KIRO_AGENT_MODE,
)
def build_kiro_ide_tag(*, kiro_version: str, machine_id: str) -> str:
version = (kiro_version or DEFAULT_KIRO_VERSION).strip() or DEFAULT_KIRO_VERSION
mid = (machine_id or "").strip()
return f"KiroIDE-{version}-{mid}" if mid else f"KiroIDE-{version}"
def build_x_amz_user_agent_main(*, kiro_version: str, machine_id: str) -> str:
return f"aws-sdk-js/{AWS_SDK_JS_MAIN_VERSION} {build_kiro_ide_tag(kiro_version=kiro_version, machine_id=machine_id)}"
def build_user_agent_main(
*, system_version: str, node_version: str, kiro_version: str, machine_id: str
) -> str:
os_tag = (system_version or DEFAULT_SYSTEM_VERSION).strip() or DEFAULT_SYSTEM_VERSION
node_tag = (node_version or DEFAULT_NODE_VERSION).strip() or DEFAULT_NODE_VERSION
ide = build_kiro_ide_tag(kiro_version=kiro_version, machine_id=machine_id)
return (
f"aws-sdk-js/{AWS_SDK_JS_MAIN_VERSION} ua/2.1 os/{os_tag} lang/js "
f"md/nodejs#{node_tag} api/codewhispererstreaming#{AWS_SDK_JS_MAIN_VERSION} m/E {ide}"
)
def build_x_amz_user_agent_usage(*, kiro_version: str, machine_id: str) -> str:
ide = build_kiro_ide_tag(kiro_version=kiro_version, machine_id=machine_id)
return f"aws-sdk-js/{AWS_SDK_JS_USAGE_VERSION} {ide}"
def build_user_agent_usage(*, kiro_version: str, machine_id: str) -> str:
ide = build_kiro_ide_tag(kiro_version=kiro_version, machine_id=machine_id)
os_tag = DEFAULT_SYSTEM_VERSION
node_tag = DEFAULT_NODE_VERSION
return (
f"aws-sdk-js/{AWS_SDK_JS_USAGE_VERSION} ua/2.1 os/{os_tag} lang/js "
f"md/nodejs#{node_tag} api/codewhispererruntime#1.0.0 m/N,E {ide}"
)
def build_generate_assistant_headers(
*,
host: str,
access_token: str | None = None,
machine_id: str,
kiro_version: str | None = None,
system_version: str | None = None,
node_version: str | None = None,
) -> dict[str, str]:
version = (kiro_version or DEFAULT_KIRO_VERSION).strip() or DEFAULT_KIRO_VERSION
sys_ver = (system_version or DEFAULT_SYSTEM_VERSION).strip() or DEFAULT_SYSTEM_VERSION
node_ver = (node_version or DEFAULT_NODE_VERSION).strip() or DEFAULT_NODE_VERSION
headers: dict[str, str] = {
"Content-Type": "application/json",
"Accept": AWS_EVENTSTREAM_CONTENT_TYPE,
"host": host,
"Connection": "close",
"x-amzn-codewhisperer-optout": CODEWHISPERER_OPTOUT,
"x-amzn-kiro-agent-mode": KIRO_AGENT_MODE,
"x-amz-user-agent": build_x_amz_user_agent_main(
kiro_version=version, machine_id=machine_id
),
"User-Agent": build_user_agent_main(
system_version=sys_ver,
node_version=node_ver,
kiro_version=version,
machine_id=machine_id,
),
"amz-sdk-invocation-id": str(uuid.uuid4()),
"amz-sdk-request": "attempt=1; max=3",
}
if access_token:
headers["Authorization"] = f"Bearer {access_token}"
return headers
__all__ = [
"build_generate_assistant_headers",
"build_kiro_ide_tag",
"build_user_agent_main",
"build_user_agent_usage",
"build_x_amz_user_agent_main",
"build_x_amz_user_agent_usage",
]

View File

@@ -0,0 +1,21 @@
from .credentials import KiroAuthConfig
from .usage_limits import (
Bonus,
FreeTrialInfo,
SubscriptionInfo,
UsageBreakdown,
UsageLimitsResponse,
calculate_current_usage,
calculate_total_usage_limit,
)
__all__ = [
"Bonus",
"FreeTrialInfo",
"KiroAuthConfig",
"SubscriptionInfo",
"UsageBreakdown",
"UsageLimitsResponse",
"calculate_current_usage",
"calculate_total_usage_limit",
]

View File

@@ -0,0 +1,247 @@
"""Internal Kiro credential schema (stored in ProviderAPIKey.auth_config)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
def _parse_epoch_seconds(value: object) -> int | None:
if value is None:
return None
try:
if isinstance(value, (int, float)):
return int(value)
if isinstance(value, str) and value.strip().isdigit():
return int(value.strip())
except Exception:
return None
return None
def _get_str(raw: dict[str, Any], *keys: str) -> str | None:
"""Return the first non-empty stripped string for *keys*, or ``None``."""
for k in keys:
v = raw.get(k)
if isinstance(v, str) and v.strip():
return v.strip()
return None
def _nonempty(s: str | None) -> str | None:
"""Return *s* if it's a non-empty stripped string, else ``None``."""
if isinstance(s, str) and s.strip():
return s.strip()
return None
def _normalize_auth_method(value: str | None) -> str:
method = (value or "").strip().lower()
if not method:
return "social"
# 历史/别名兼容:统一映射到 idc
if method in {
"idc",
"builder-id",
"builder_id",
"builderid",
"identity-center",
"identity_center",
"identitycenter",
"iam",
"device",
"device_authorization",
"device-auth",
}:
return "idc"
return method
def _parse_iso_to_epoch_seconds(value: object) -> int | None:
if not isinstance(value, str) or not value.strip():
return None
text = value.strip()
# Support RFC3339 with Z suffix.
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(text)
except Exception:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp())
@dataclass(slots=True)
class KiroAuthConfig:
provider_type: str = "kiro"
auth_method: str = "social" # social | idc
refresh_token: str = ""
expires_at: int = 0
profile_arn: str | None = None
region: str | None = None # OIDC regionIdC token 刷新用)
# 独立的 auth / api region与 kiro.rs 对齐)
# auth_region: token 刷新端点,未设置时回退到 region
# api_region: q.{region} 服务端点,未设置时回退到 DEFAULT_REGION
auth_region: str | None = None
api_region: str | None = None
client_id: str | None = None
client_secret: str | None = None
machine_id: str | None = None
kiro_version: str | None = None
system_version: str | None = None
node_version: str | None = None
email: str | None = None # 账号邮箱
# 缓存的 access_token可选用于避免频繁刷新
access_token: str | None = None
def effective_auth_region(self) -> str:
"""Token 刷新用的 region。
优先级: auth_region > region > DEFAULT_REGION
"""
from src.services.provider.adapters.kiro.constants import DEFAULT_REGION
return _nonempty(self.auth_region) or _nonempty(self.region) or DEFAULT_REGION
def effective_api_region(self) -> str:
"""API 服务端点q.{region})用的 region。
优先级: api_region > DEFAULT_REGION
注意: 不从 region 继承,因为 region 通常是 OIDC region如 eu-north-1
而 q.{region} 端点目前仅 us-east-1 可用。
"""
from src.services.provider.adapters.kiro.constants import DEFAULT_REGION
return _nonempty(self.api_region) or DEFAULT_REGION
@staticmethod
def infer_auth_method(raw: dict[str, Any]) -> str:
"""
根据凭据字段自动推断认证类型。
规则:
- 包含 clientId + clientSecret -> IdC
- 仅含 refreshToken -> Social
"""
explicit_method = _get_str(raw, "auth_method", "authMethod", "auth_type", "authType")
normalized_explicit = _normalize_auth_method(explicit_method)
if normalized_explicit != "social":
return normalized_explicit
client_id = raw.get("client_id") or raw.get("clientId")
client_secret = raw.get("client_secret") or raw.get("clientSecret")
if client_id and client_secret:
return "idc"
return "social"
@staticmethod
def validate_required_fields(raw: dict[str, Any]) -> tuple[bool, str]:
"""
验证凭据是否包含必需字段。
返回: (is_valid, error_message)
"""
refresh_token = raw.get("refresh_token") or raw.get("refreshToken") or ""
refresh_token = str(refresh_token).strip()
if not refresh_token:
return False, "refreshToken 为必填字段"
# refreshToken 不能含有 ...(表示被截断)
if "..." in refresh_token:
return False, "refreshToken 不完整(含有 ...),请导出完整的 Token"
# IdC 类型需要 clientId 和 clientSecret
explicit_method = _get_str(raw, "auth_method", "authMethod", "auth_type", "authType")
auth_method = (
_normalize_auth_method(explicit_method)
if explicit_method
else KiroAuthConfig.infer_auth_method(raw)
)
if auth_method == "idc":
client_id = raw.get("client_id") or raw.get("clientId")
client_secret = raw.get("client_secret") or raw.get("clientSecret")
if not client_id:
return False, "IdC 类型需要 clientId"
if not client_secret:
return False, "IdC 类型需要 clientSecret"
return True, ""
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "KiroAuthConfig":
if not isinstance(raw, dict):
raw = {}
provider_type = _get_str(raw, "provider_type", "providerType") or "kiro"
# 自动推断 auth_method如果未显式指定
explicit_method = _get_str(raw, "auth_method", "authMethod", "auth_type", "authType")
auth_method = (
_normalize_auth_method(explicit_method)
if explicit_method
else cls.infer_auth_method(raw)
)
refresh_token = (_get_str(raw, "refresh_token", "refreshToken") or "").strip()
expires_at = _parse_epoch_seconds(raw.get("expires_at"))
if expires_at is None:
expires_at = _parse_iso_to_epoch_seconds(raw.get("expiresAt"))
if expires_at is None:
expires_at = 0
cfg = cls(
provider_type=provider_type,
auth_method=_normalize_auth_method(auth_method),
refresh_token=refresh_token,
expires_at=int(expires_at),
profile_arn=_get_str(raw, "profile_arn", "profileArn"),
region=_get_str(raw, "region"),
auth_region=_get_str(raw, "auth_region", "authRegion"),
api_region=_get_str(raw, "api_region", "apiRegion"),
client_id=_get_str(raw, "client_id", "clientId"),
client_secret=_get_str(raw, "client_secret", "clientSecret"),
machine_id=_get_str(raw, "machine_id", "machineId"),
kiro_version=_get_str(raw, "kiro_version", "kiroVersion"),
system_version=_get_str(raw, "system_version", "systemVersion"),
node_version=_get_str(raw, "node_version", "nodeVersion"),
email=_get_str(raw, "email"),
access_token=_get_str(raw, "access_token", "accessToken"),
)
return cfg
def to_dict(self) -> dict[str, Any]:
return {
"provider_type": self.provider_type,
"auth_method": self.auth_method,
"refresh_token": self.refresh_token,
"expires_at": self.expires_at,
"profile_arn": self.profile_arn,
"region": self.region,
"auth_region": self.auth_region,
"api_region": self.api_region,
"client_id": self.client_id,
"client_secret": self.client_secret,
"machine_id": self.machine_id,
"kiro_version": self.kiro_version,
"system_version": self.system_version,
"node_version": self.node_version,
"email": self.email,
"access_token": self.access_token,
}
__all__ = ["KiroAuthConfig"]

View File

@@ -0,0 +1,222 @@
"""Kiro getUsageLimits response models (best-effort).
The AWS API uses camelCase fields; we parse defensively.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class SubscriptionInfo:
subscription_title: str | None = None
@classmethod
def from_dict(cls, raw: Any) -> "SubscriptionInfo | None":
if not isinstance(raw, dict):
return None
title = raw.get("subscriptionTitle")
if isinstance(title, str) and title.strip():
return cls(subscription_title=title.strip())
return cls(subscription_title=None)
@dataclass(slots=True)
class Bonus:
current_usage: float = 0.0
usage_limit: float = 0.0
status: str | None = None
@classmethod
def from_dict(cls, raw: Any) -> "Bonus | None":
if not isinstance(raw, dict):
return None
status = raw.get("status")
status_val = status.strip() if isinstance(status, str) and status.strip() else None
cu = raw.get("currentUsage")
ul = raw.get("usageLimit")
try:
cu_f = float(cu) if cu is not None else 0.0
except Exception:
cu_f = 0.0
try:
ul_f = float(ul) if ul is not None else 0.0
except Exception:
ul_f = 0.0
return cls(current_usage=cu_f, usage_limit=ul_f, status=status_val)
@dataclass(slots=True)
class FreeTrialInfo:
current_usage: int = 0
current_usage_with_precision: float = 0.0
usage_limit: int = 0
usage_limit_with_precision: float = 0.0
free_trial_expiry: float | None = None
free_trial_status: str | None = None
@classmethod
def from_dict(cls, raw: Any) -> "FreeTrialInfo | None":
if not isinstance(raw, dict):
return None
def _int(v: Any) -> int:
try:
return int(v)
except Exception:
return 0
def _float(v: Any) -> float:
try:
return float(v)
except Exception:
return 0.0
expiry = raw.get("freeTrialExpiry")
try:
expiry_f = float(expiry) if expiry is not None else None
except Exception:
expiry_f = None
status = raw.get("freeTrialStatus")
status_val = status.strip() if isinstance(status, str) and status.strip() else None
return cls(
current_usage=_int(raw.get("currentUsage")),
current_usage_with_precision=_float(raw.get("currentUsageWithPrecision")),
usage_limit=_int(raw.get("usageLimit")),
usage_limit_with_precision=_float(raw.get("usageLimitWithPrecision")),
free_trial_expiry=expiry_f,
free_trial_status=status_val,
)
@dataclass(slots=True)
class UsageBreakdown:
current_usage: int = 0
current_usage_with_precision: float = 0.0
usage_limit: int = 0
usage_limit_with_precision: float = 0.0
next_date_reset: float | None = None
bonuses: list[Bonus] = field(default_factory=list)
free_trial_info: FreeTrialInfo | None = None
@classmethod
def from_dict(cls, raw: Any) -> "UsageBreakdown | None":
if not isinstance(raw, dict):
return None
def _int(v: Any) -> int:
try:
return int(v)
except Exception:
return 0
def _float(v: Any) -> float:
try:
return float(v)
except Exception:
return 0.0
next_reset = raw.get("nextDateReset")
try:
next_reset_f = float(next_reset) if next_reset is not None else None
except Exception:
next_reset_f = None
bonuses_raw = raw.get("bonuses")
bonuses: list[Bonus] = []
if isinstance(bonuses_raw, list):
for b in bonuses_raw:
parsed = Bonus.from_dict(b)
if parsed is not None:
bonuses.append(parsed)
return cls(
current_usage=_int(raw.get("currentUsage")),
current_usage_with_precision=_float(raw.get("currentUsageWithPrecision")),
usage_limit=_int(raw.get("usageLimit")),
usage_limit_with_precision=_float(raw.get("usageLimitWithPrecision")),
next_date_reset=next_reset_f,
bonuses=bonuses,
free_trial_info=FreeTrialInfo.from_dict(raw.get("freeTrialInfo")),
)
@dataclass(slots=True)
class UsageLimitsResponse:
next_date_reset: float | None = None
subscription_info: SubscriptionInfo | None = None
usage_breakdown_list: list[UsageBreakdown] = field(default_factory=list)
@classmethod
def from_dict(cls, raw: Any) -> "UsageLimitsResponse":
if not isinstance(raw, dict):
raw = {}
next_reset = raw.get("nextDateReset")
try:
next_reset_f = float(next_reset) if next_reset is not None else None
except Exception:
next_reset_f = None
breakdown_raw = raw.get("usageBreakdownList")
breakdowns: list[UsageBreakdown] = []
if isinstance(breakdown_raw, list):
for b in breakdown_raw:
parsed = UsageBreakdown.from_dict(b)
if parsed is not None:
breakdowns.append(parsed)
return cls(
next_date_reset=next_reset_f,
subscription_info=SubscriptionInfo.from_dict(raw.get("subscriptionInfo")),
usage_breakdown_list=breakdowns,
)
def calculate_total_usage_limit(response: UsageLimitsResponse) -> float:
if not response.usage_breakdown_list:
return 0.0
breakdown = response.usage_breakdown_list[0]
total = breakdown.usage_limit_with_precision
if breakdown.free_trial_info and breakdown.free_trial_info.free_trial_status == "ACTIVE":
total += breakdown.free_trial_info.usage_limit_with_precision
for bonus in breakdown.bonuses:
if bonus.status == "ACTIVE":
total += bonus.usage_limit
return total
def calculate_current_usage(response: UsageLimitsResponse) -> float:
if not response.usage_breakdown_list:
return 0.0
breakdown = response.usage_breakdown_list[0]
total = breakdown.current_usage_with_precision
if breakdown.free_trial_info and breakdown.free_trial_info.free_trial_status == "ACTIVE":
total += breakdown.free_trial_info.current_usage_with_precision
for bonus in breakdown.bonuses:
if bonus.status == "ACTIVE":
total += bonus.current_usage
return total
__all__ = [
"Bonus",
"FreeTrialInfo",
"SubscriptionInfo",
"UsageBreakdown",
"UsageLimitsResponse",
"calculate_current_usage",
"calculate_total_usage_limit",
]

View File

@@ -0,0 +1,6 @@
"""AWS Event Stream parser for Kiro."""
from .decoder import EventStreamDecoder
from .frame import Frame
__all__ = ["EventStreamDecoder", "Frame"]

View File

@@ -0,0 +1,13 @@
"""CRC helpers for AWS Event Stream frames."""
from __future__ import annotations
import binascii
def crc32(data: bytes) -> int:
"""Compute unsigned CRC32 (IEEE)."""
return binascii.crc32(data) & 0xFFFFFFFF
__all__ = ["crc32"]

View File

@@ -0,0 +1,91 @@
"""Incremental AWS Event Stream decoder."""
from __future__ import annotations
from dataclasses import dataclass
from .error import BufferOverflowError, EventStreamParseError
from .frame import MAX_MESSAGE_SIZE, Frame, parse_frame
DEFAULT_MAX_BUFFER_SIZE = MAX_MESSAGE_SIZE
DEFAULT_MAX_ERRORS = 5
@dataclass(slots=True)
class DecoderStats:
frames_decoded: int = 0
bytes_skipped: int = 0
error_count: int = 0
class EventStreamDecoder:
def __init__(
self,
*,
max_buffer_size: int = DEFAULT_MAX_BUFFER_SIZE,
max_errors: int = DEFAULT_MAX_ERRORS,
) -> None:
self._buffer = bytearray()
self._max_buffer_size = int(max_buffer_size)
self._max_errors = int(max_errors)
self._stopped = False
self.stats = DecoderStats()
@property
def stopped(self) -> bool:
return self._stopped
def feed(self, data: bytes) -> None:
if self._stopped:
return
if not data:
return
new_size = len(self._buffer) + len(data)
if new_size > self._max_buffer_size:
self._stopped = True
raise BufferOverflowError(size=new_size, max_size=self._max_buffer_size)
self._buffer.extend(data)
def decode_available(self) -> list[Frame]:
"""Decode all complete frames currently in buffer."""
out: list[Frame] = []
if self._stopped:
return out
while True:
try:
# Use memoryview to avoid full buffer copy on each iteration
parsed = parse_frame(memoryview(self._buffer))
except EventStreamParseError:
self.stats.error_count += 1
if self.stats.error_count >= self._max_errors:
self._stopped = True
raise
# Recovery: skip a byte and keep scanning.
if self._buffer:
del self._buffer[0]
self.stats.bytes_skipped += 1
else:
break
continue
if parsed is None:
break
frame, consumed = parsed
if consumed <= 0:
break
out.append(frame)
del self._buffer[:consumed]
self.stats.frames_decoded += 1
self.stats.error_count = 0
return out
__all__ = [
"DecoderStats",
"EventStreamDecoder",
]

View File

@@ -0,0 +1,72 @@
"""AWS Event Stream parsing errors."""
from __future__ import annotations
class EventStreamParseError(Exception):
"""Base error for AWS Event Stream decoding."""
class IncompleteFrameError(EventStreamParseError):
def __init__(self, *, needed: int, available: int) -> None:
super().__init__(f"incomplete frame: needed={needed} available={available}")
self.needed = needed
self.available = available
class MessageTooSmallError(EventStreamParseError):
def __init__(self, *, length: int, min_length: int) -> None:
super().__init__(f"message too small: length={length} min={min_length}")
self.length = length
self.min_length = min_length
class MessageTooLargeError(EventStreamParseError):
def __init__(self, *, length: int, max_length: int) -> None:
super().__init__(f"message too large: length={length} max={max_length}")
self.length = length
self.max_length = max_length
class PreludeCrcMismatchError(EventStreamParseError):
def __init__(self, *, expected: int, actual: int) -> None:
super().__init__(f"prelude crc mismatch: expected={expected} actual={actual}")
self.expected = expected
self.actual = actual
class MessageCrcMismatchError(EventStreamParseError):
def __init__(self, *, expected: int, actual: int) -> None:
super().__init__(f"message crc mismatch: expected={expected} actual={actual}")
self.expected = expected
self.actual = actual
class InvalidHeaderTypeError(EventStreamParseError):
def __init__(self, type_id: int) -> None:
super().__init__(f"invalid header type: {type_id}")
self.type_id = type_id
class HeaderParseError(EventStreamParseError):
pass
class BufferOverflowError(EventStreamParseError):
def __init__(self, *, size: int, max_size: int) -> None:
super().__init__(f"buffer overflow: size={size} max={max_size}")
self.size = size
self.max_size = max_size
__all__ = [
"BufferOverflowError",
"EventStreamParseError",
"HeaderParseError",
"IncompleteFrameError",
"InvalidHeaderTypeError",
"MessageCrcMismatchError",
"MessageTooLargeError",
"MessageTooSmallError",
"PreludeCrcMismatchError",
]

View File

@@ -0,0 +1,95 @@
"""AWS Event Stream message frame parsing."""
from __future__ import annotations
from dataclasses import dataclass
from .crc import crc32
from .error import (
HeaderParseError,
IncompleteFrameError,
MessageCrcMismatchError,
MessageTooLargeError,
MessageTooSmallError,
PreludeCrcMismatchError,
)
from .header import Headers, parse_headers
PRELUDE_SIZE = 12
MIN_MESSAGE_SIZE = PRELUDE_SIZE + 4
MAX_MESSAGE_SIZE = 16 * 1024 * 1024
@dataclass(slots=True)
class Frame:
headers: Headers
payload: bytes
def message_type(self) -> str | None:
return self.headers.message_type()
def event_type(self) -> str | None:
return self.headers.event_type()
def payload_as_text(self) -> str:
return self.payload.decode("utf-8", errors="replace")
def parse_frame(buffer: bytes | memoryview) -> tuple[Frame, int] | None:
"""Parse a single frame from the front of buffer.
Returns:
(frame, consumed_bytes) if a full frame is available, otherwise None.
Raises:
EventStreamParseError subclasses on validation errors.
"""
if len(buffer) < PRELUDE_SIZE:
return None
total_length = int.from_bytes(buffer[0:4], "big", signed=False)
header_length = int.from_bytes(buffer[4:8], "big", signed=False)
prelude_crc = int.from_bytes(buffer[8:12], "big", signed=False)
if total_length < MIN_MESSAGE_SIZE:
raise MessageTooSmallError(length=total_length, min_length=MIN_MESSAGE_SIZE)
if total_length > MAX_MESSAGE_SIZE:
raise MessageTooLargeError(length=total_length, max_length=MAX_MESSAGE_SIZE)
if len(buffer) < total_length:
return None
actual_prelude_crc = crc32(buffer[0:8])
if actual_prelude_crc != prelude_crc:
raise PreludeCrcMismatchError(expected=prelude_crc, actual=actual_prelude_crc)
message_crc = int.from_bytes(buffer[total_length - 4 : total_length], "big", signed=False)
actual_message_crc = crc32(buffer[0 : total_length - 4])
if actual_message_crc != message_crc:
raise MessageCrcMismatchError(expected=message_crc, actual=actual_message_crc)
headers_start = PRELUDE_SIZE
headers_end = headers_start + header_length
if headers_end > total_length - 4:
raise HeaderParseError("header length exceeds frame boundary")
headers = parse_headers(bytes(buffer[headers_start:headers_end]), header_length)
payload_start = headers_end
payload_end = total_length - 4
if payload_end < payload_start:
raise IncompleteFrameError(needed=payload_start, available=payload_end)
payload = bytes(buffer[payload_start:payload_end])
return Frame(headers=headers, payload=payload), total_length
__all__ = [
"Frame",
"MAX_MESSAGE_SIZE",
"MIN_MESSAGE_SIZE",
"PRELUDE_SIZE",
"parse_frame",
]

View File

@@ -0,0 +1,144 @@
"""AWS Event Stream header parsing."""
from __future__ import annotations
from dataclasses import dataclass
from enum import IntEnum
from .error import HeaderParseError, IncompleteFrameError, InvalidHeaderTypeError
class HeaderValueType(IntEnum):
BOOL_TRUE = 0
BOOL_FALSE = 1
BYTE = 2
SHORT = 3
INTEGER = 4
LONG = 5
BYTE_ARRAY = 6
STRING = 7
TIMESTAMP = 8
UUID = 9
@dataclass(slots=True)
class Headers:
values: dict[str, object]
def get(self, name: str) -> object | None:
return self.values.get(name)
def get_string(self, name: str) -> str | None:
v = self.values.get(name)
return v if isinstance(v, str) else None
def message_type(self) -> str | None:
return self.get_string(":message-type")
def event_type(self) -> str | None:
return self.get_string(":event-type")
def exception_type(self) -> str | None:
return self.get_string(":exception-type")
def error_code(self) -> str | None:
return self.get_string(":error-code")
def _ensure_bytes(data: bytes, offset: int, needed: int) -> None:
available = len(data) - offset
if available < needed:
raise IncompleteFrameError(needed=needed, available=available)
def parse_headers(data: bytes, header_length: int) -> Headers:
if len(data) < header_length:
raise IncompleteFrameError(needed=header_length, available=len(data))
values: dict[str, object] = {}
offset = 0
while offset < header_length:
_ensure_bytes(data, offset, 1)
name_len = data[offset]
offset += 1
if name_len == 0:
raise HeaderParseError("header name length cannot be 0")
_ensure_bytes(data, offset, name_len)
name = data[offset : offset + name_len].decode("utf-8", errors="replace")
offset += name_len
_ensure_bytes(data, offset, 1)
type_id = data[offset]
offset += 1
try:
value_type = HeaderValueType(type_id)
except ValueError as e:
raise InvalidHeaderTypeError(type_id) from e
if value_type == HeaderValueType.BOOL_TRUE:
values[name] = True
continue
if value_type == HeaderValueType.BOOL_FALSE:
values[name] = False
continue
if value_type == HeaderValueType.BYTE:
_ensure_bytes(data, offset, 1)
values[name] = int.from_bytes(data[offset : offset + 1], "big", signed=True)
offset += 1
continue
if value_type == HeaderValueType.SHORT:
_ensure_bytes(data, offset, 2)
values[name] = int.from_bytes(data[offset : offset + 2], "big", signed=True)
offset += 2
continue
if value_type == HeaderValueType.INTEGER:
_ensure_bytes(data, offset, 4)
values[name] = int.from_bytes(data[offset : offset + 4], "big", signed=True)
offset += 4
continue
if value_type in (HeaderValueType.LONG, HeaderValueType.TIMESTAMP):
_ensure_bytes(data, offset, 8)
values[name] = int.from_bytes(data[offset : offset + 8], "big", signed=True)
offset += 8
continue
if value_type == HeaderValueType.BYTE_ARRAY:
_ensure_bytes(data, offset, 2)
length = int.from_bytes(data[offset : offset + 2], "big", signed=False)
offset += 2
_ensure_bytes(data, offset, length)
values[name] = data[offset : offset + length]
offset += length
continue
if value_type == HeaderValueType.STRING:
_ensure_bytes(data, offset, 2)
length = int.from_bytes(data[offset : offset + 2], "big", signed=False)
offset += 2
_ensure_bytes(data, offset, length)
values[name] = data[offset : offset + length].decode("utf-8", errors="replace")
offset += length
continue
if value_type == HeaderValueType.UUID:
_ensure_bytes(data, offset, 16)
values[name] = bytes(data[offset : offset + 16])
offset += 16
continue
raise HeaderParseError(f"unhandled header type: {value_type}")
return Headers(values=values)
__all__ = [
"HeaderValueType",
"Headers",
"parse_headers",
]

View File

@@ -0,0 +1,133 @@
"""Kiro provider plugin — unified registration entry.
Kiro upstream looks like Claude CLI (Bearer token) from the outside, but uses a
custom wire protocol:
- Request: Claude Messages API -> Kiro generateAssistantResponse envelope
- Response (stream): AWS Event Stream (binary) -> Claude SSE events
This plugin registers:
- Envelope
- Transport hook (dynamic region base_url)
- Model fetcher (fixed model catalog — Kiro has no /v1/models endpoint)
"""
from __future__ import annotations
from typing import Any
from urllib.parse import urlencode
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
# ---------------------------------------------------------------------------
# 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")
# ---------------------------------------------------------------------------
# Transport hook
# ---------------------------------------------------------------------------
def build_kiro_url(
endpoint: Any,
*,
is_stream: bool,
effective_query_params: dict[str, Any],
**_kwargs: Any,
) -> str:
"""Build Kiro generateAssistantResponse URL.
Endpoint base_url may contain a `{region}` placeholder. The actual region is
resolved from per-request context (set by the envelope).
"""
_ = is_stream
ctx = get_kiro_request_context()
region = (ctx.region if ctx else "") or DEFAULT_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)
url = build_kiro_generate_assistant_url(raw_base, cfg=cfg)
if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
return url
# ---------------------------------------------------------------------------
# Export builder
# ---------------------------------------------------------------------------
_KIRO_SKIP_KEYS = frozenset(
{
"access_token",
"expires_at",
"updated_at",
}
)
def kiro_export_builder(
auth_config: dict[str, Any],
upstream_metadata: dict[str, Any] | None,
) -> dict[str, Any]:
"""Kiro 导出:保留 auth_method / refresh_token / machine_id / profile_arn 等,
IdC 模式额外保留 client_id / client_secret / region。"""
data = {
k: v
for k, v in auth_config.items()
if k not in _KIRO_SKIP_KEYS and v is not None and v != ""
}
# email 可能仅在 upstream_metadata.kiro 中
if not data.get("email"):
kiro_meta = (upstream_metadata or {}).get("kiro") or {}
if kiro_meta.get("email"):
data["email"] = kiro_meta["email"]
return data
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
def register_all() -> None:
"""Register all Kiro hooks into shared registries."""
from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
from src.services.provider.adapters.kiro.envelope import kiro_envelope
from src.services.provider.envelope import register_envelope
from src.services.provider.export import register_export_builder
from src.services.provider.transport import register_transport_hook
register_envelope("kiro", "claude:cli", kiro_envelope)
register_envelope("kiro", "", kiro_envelope)
register_transport_hook("kiro", "claude:cli", build_kiro_url)
register_export_builder("kiro", kiro_export_builder)
UpstreamModelsFetcherRegistry.register(
provider_types=["kiro"],
fetcher=fetch_models_kiro,
)
__all__ = ["build_kiro_url", "fetch_models_kiro", "kiro_export_builder", "register_all"]

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

@@ -0,0 +1,108 @@
"""Shared Rust executor HTTP helper for Kiro provider side calls."""
from __future__ import annotations
import json
from typing import Any
import httpx
from src.config.settings import config
from src.core.logger import logger
async def execute_kiro_rust_http_request(
*,
method: str,
url: str,
headers: dict[str, str],
body: Any,
proxy_config: dict[str, Any] | None,
request_id: str,
provider_api_format: str,
content_type: str | None = None,
timeout_seconds: float = 30.0,
) -> httpx.Response | None:
from src.services.request.execution_runtime_plan import (
ExecutionPlan,
ExecutionPlanBody,
ExecutionPlanTimeouts,
build_execution_plan_body,
build_proxy_snapshot,
)
from src.services.request.execution_runtime_client import (
ExecutionRuntimeClient,
ExecutionRuntimeClientError,
)
if config.execution_runtime_backend != "rust":
return None
final_headers = dict(headers)
if (
body is not None
and content_type
and not any(str(key).lower() == "content-type" for key in final_headers)
):
final_headers["content-type"] = content_type
timeout_ms = max(int(timeout_seconds * 1000), 1_000)
try:
proxy_snapshot = await build_proxy_snapshot(proxy_config, label="Kiro")
result = await ExecutionRuntimeClient().execute_sync_json(
ExecutionPlan(
request_id=request_id,
candidate_id=None,
provider_name="kiro",
provider_id="",
endpoint_id="",
key_id="",
method=method,
url=url,
headers=final_headers,
body=(
build_execution_plan_body(body, content_type=content_type)
if body is not None
else ExecutionPlanBody()
),
stream=False,
provider_api_format=provider_api_format,
client_api_format=provider_api_format,
model_name="kiro",
content_type=content_type,
proxy=proxy_snapshot,
timeouts=ExecutionPlanTimeouts(
connect_ms=timeout_ms,
read_ms=timeout_ms,
write_ms=timeout_ms,
pool_ms=timeout_ms,
total_ms=timeout_ms,
),
)
)
except (ExecutionRuntimeClientError, httpx.HTTPError, json.JSONDecodeError) as exc:
logger.warning("Kiro Rust HTTP fallback {} {}: {}", method, url, exc)
return None
except Exception as exc:
logger.warning("Kiro Rust HTTP unexpected fallback {} {}: {}", method, url, exc)
return None
response_headers = dict(result.headers)
if result.response_json is not None:
response_headers.setdefault("content-type", "application/json")
response_body = json.dumps(result.response_json, ensure_ascii=False).encode("utf-8")
elif result.response_body_bytes is not None:
response_body = result.response_body_bytes
else:
response_body = b""
return httpx.Response(
status_code=result.status_code,
request=httpx.Request(method, url, headers=final_headers),
headers=response_headers,
content=response_body,
)
__all__ = ["execute_kiro_rust_http_request"]

View File

@@ -0,0 +1,346 @@
"""Kiro token refresh helpers."""
from __future__ import annotations
import base64
import hashlib
import json
import re
import time
from typing import Any
import httpx
from src.clients.http_client import HTTPClientPool
from src.core.logger import logger
from src.services.provider.adapters.kiro.headers import build_kiro_ide_tag
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
from src.services.provider.adapters.kiro.rust_http import execute_kiro_rust_http_request
IDC_AMZ_USER_AGENT = (
"aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown "
"api/sso-oidc#3.738.0 m/E KiroIDE"
)
_REGION_RE = re.compile(r"^[a-z]{2}-[a-z0-9-]+-\d+$")
_HEX64_RE = re.compile(r"^[0-9a-fA-F]{64}$")
_UUID_RE = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
def validate_refresh_token(refresh_token: str) -> None:
token = str(refresh_token or "").strip()
if not token:
raise ValueError("missing refresh_token")
# kiro.rs: length < 100 or contains "..." is considered truncated.
if len(token) < 100 or token.endswith("...") or "..." in token:
raise ValueError(
"refresh_token appears truncated; please export the full token from Kiro IDE"
)
def normalize_machine_id(machine_id: str) -> str | None:
raw = str(machine_id or "").strip()
if not raw:
return None
if _HEX64_RE.fullmatch(raw):
return raw.lower()
if _UUID_RE.fullmatch(raw):
without = raw.replace("-", "").lower()
return without + without
return None
def generate_machine_id(cfg: KiroAuthConfig) -> str:
normalized = normalize_machine_id(cfg.machine_id or "")
if normalized:
return normalized
validate_refresh_token(cfg.refresh_token)
seed = f"KotlinNativeAPI/{cfg.refresh_token}".encode("utf-8")
return hashlib.sha256(seed).hexdigest()
def is_token_expired(expires_at: int | None, *, skew_seconds: int = 120) -> bool:
try:
ts = int(expires_at or 0)
except Exception:
ts = 0
if ts <= 0:
return True
return int(time.time()) >= ts - int(skew_seconds)
def _resolve_region(cfg: KiroAuthConfig) -> str:
"""解析 token 刷新端点的 region。"""
region = cfg.effective_auth_region()
if _REGION_RE.fullmatch(region):
return region
from src.services.provider.adapters.kiro.constants import DEFAULT_REGION
return DEFAULT_REGION
def _try_extract_email_from_jwt(token: str) -> str | None:
"""尝试从 JWT access_token 中提取 email。
Kiro Social / IdC 返回的 accessToken 可能是 JWT 格式,
payload 中可能包含 email 字段。仅做 base64 解码,不验证签名。
失败时静默返回 None。
"""
try:
parts = token.split(".")
if len(parts) != 3:
return None
# base64url decode the payload (second segment)
payload_b64 = parts[1]
# Add padding
padding = 4 - len(payload_b64) % 4
if padding != 4:
payload_b64 += "=" * padding
payload_bytes = base64.urlsafe_b64decode(payload_b64)
claims = json.loads(payload_bytes)
# Try common email claim keys
for key in ("email", "Email", "mail", "upn"):
val = claims.get(key)
if isinstance(val, str) and "@" in val:
return val.strip()
except Exception:
pass
return None
async def refresh_social_token(
cfg: KiroAuthConfig,
*,
proxy_config: dict[str, Any] | None,
timeout_seconds: float = 30.0,
) -> tuple[str, KiroAuthConfig]:
"""Refresh access token via Kiro Social refresh endpoint."""
validate_refresh_token(cfg.refresh_token)
region = _resolve_region(cfg)
url = f"https://prod.{region}.auth.desktop.kiro.dev/refreshToken"
host = f"prod.{region}.auth.desktop.kiro.dev"
machine_id = generate_machine_id(cfg)
kiro_version = (cfg.kiro_version or "").strip() or "0.8.0"
ua = build_kiro_ide_tag(kiro_version=kiro_version, machine_id=machine_id)
body = {"refreshToken": cfg.refresh_token}
headers = {
"User-Agent": ua,
"Host": host,
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Connection": "close",
"Accept-Encoding": "gzip, compress, deflate, br",
}
resp = await execute_kiro_rust_http_request(
method="POST",
url=url,
headers=headers,
body=body,
proxy_config=proxy_config,
request_id=f"kiro-social-refresh:{region}:{machine_id}",
provider_api_format="kiro:social_refresh",
content_type="application/json",
)
if resp is None:
client = await HTTPClientPool.get_proxy_client(proxy_config=proxy_config)
resp = await client.post(
url,
headers=headers,
json=body,
timeout=httpx.Timeout(timeout_seconds),
)
if resp.status_code < 200 or resp.status_code >= 300:
body_text = (resp.text or "").strip()[:500]
logger.warning(
"kiro social refresh error: HTTP {} | {}",
resp.status_code,
body_text,
)
raise RuntimeError(f"kiro social refresh failed: HTTP {resp.status_code} | {body_text}")
data: dict[str, Any]
try:
data = resp.json()
except Exception as e:
raise RuntimeError("kiro social refresh: invalid json response") from e
access_token = str(data.get("accessToken") or "").strip()
if not access_token:
raise RuntimeError("kiro social refresh returned empty accessToken")
new_cfg = KiroAuthConfig.from_dict(cfg.to_dict())
# refreshToken/profileArn may rotate
rt = data.get("refreshToken")
if isinstance(rt, str) and rt.strip():
new_cfg.refresh_token = rt.strip()
profile_arn = data.get("profileArn")
if isinstance(profile_arn, str) and profile_arn.strip():
new_cfg.profile_arn = profile_arn.strip()
expires_in = data.get("expiresIn")
try:
if expires_in is not None:
new_cfg.expires_at = int(time.time()) + int(expires_in)
except Exception:
new_cfg.expires_at = int(time.time()) + 3600
# Persist computed machine_id if user didn't provide one.
if not (cfg.machine_id or "").strip():
new_cfg.machine_id = machine_id
# 尝试从 accessToken 中提取 email如果尚未设置
if not (new_cfg.email or "").strip():
extracted_email = _try_extract_email_from_jwt(access_token)
if extracted_email:
new_cfg.email = extracted_email
logger.debug("kiro social: extracted email from accessToken: {}", extracted_email)
# 缓存 access_token
new_cfg.access_token = access_token
return access_token, new_cfg
async def refresh_idc_token(
cfg: KiroAuthConfig,
*,
proxy_config: dict[str, Any] | None,
timeout_seconds: float = 30.0,
) -> tuple[str, KiroAuthConfig]:
"""Refresh access token via AWS SSO OIDC endpoint (IdC)."""
validate_refresh_token(cfg.refresh_token)
if not (cfg.client_id or "").strip() or not (cfg.client_secret or "").strip():
raise ValueError("idc refresh requires client_id and client_secret")
region = _resolve_region(cfg)
url = f"https://oidc.{region}.amazonaws.com/token"
host = f"oidc.{region}.amazonaws.com"
body = {
"clientId": cfg.client_id,
"clientSecret": cfg.client_secret,
"refreshToken": cfg.refresh_token,
"grantType": "refresh_token",
}
headers = {
"Content-Type": "application/json",
"Host": host,
"x-amz-user-agent": IDC_AMZ_USER_AGENT,
"User-Agent": "node",
"Accept": "*/*",
}
resp = await execute_kiro_rust_http_request(
method="POST",
url=url,
headers=headers,
body=body,
proxy_config=proxy_config,
request_id=f"kiro-idc-refresh:{region}",
provider_api_format="kiro:idc_refresh",
content_type="application/json",
)
if resp is None:
client = await HTTPClientPool.get_proxy_client(proxy_config=proxy_config)
resp = await client.post(
url,
headers=headers,
json=body,
timeout=httpx.Timeout(timeout_seconds),
)
if resp.status_code < 200 or resp.status_code >= 300:
body_text = (resp.text or "").strip()[:500]
logger.warning(
"kiro idc refresh error: HTTP {} | {}",
resp.status_code,
body_text,
)
raise RuntimeError(f"kiro idc refresh failed: HTTP {resp.status_code} | {body_text}")
data: dict[str, Any]
try:
data = resp.json()
except Exception as e:
raise RuntimeError("kiro idc refresh: invalid json response") from e
access_token = str(data.get("accessToken") or "").strip()
if not access_token:
raise RuntimeError("kiro idc refresh returned empty accessToken")
new_cfg = KiroAuthConfig.from_dict(cfg.to_dict())
rt = data.get("refreshToken")
if isinstance(rt, str) and rt.strip():
new_cfg.refresh_token = rt.strip()
expires_in = data.get("expiresIn")
try:
if expires_in is not None:
new_cfg.expires_at = int(time.time()) + int(expires_in)
except Exception:
new_cfg.expires_at = int(time.time()) + 3600
# Persist computed machine_id if user didn't provide one.
if not (cfg.machine_id or "").strip():
new_cfg.machine_id = generate_machine_id(cfg)
# 尝试从 accessToken 中提取 email如果尚未设置
if not (new_cfg.email or "").strip():
extracted_email = _try_extract_email_from_jwt(access_token)
if extracted_email:
new_cfg.email = extracted_email
logger.debug("kiro idc: extracted email from accessToken: {}", extracted_email)
# 缓存 access_token
new_cfg.access_token = access_token
return access_token, new_cfg
async def refresh_access_token(
cfg: KiroAuthConfig,
*,
proxy_config: dict[str, Any] | None,
timeout_seconds: float = 30.0,
) -> tuple[str, KiroAuthConfig]:
method = (cfg.auth_method or "social").strip().lower()
if method == "idc":
return await refresh_idc_token(
cfg,
proxy_config=proxy_config,
timeout_seconds=timeout_seconds,
)
return await refresh_social_token(
cfg,
proxy_config=proxy_config,
timeout_seconds=timeout_seconds,
)
__all__ = [
"IDC_AMZ_USER_AGENT",
"generate_machine_id",
"is_token_expired",
"normalize_machine_id",
"refresh_access_token",
"refresh_idc_token",
"refresh_social_token",
"validate_refresh_token",
]

View File

@@ -0,0 +1,253 @@
"""Kiro usage/quota fetching utilities."""
from __future__ import annotations
import re
import time
import uuid
from typing import Any
import httpx
from src.clients.http_client import HTTPClientPool
from src.core.logger import logger
from src.services.provider.adapters.kiro.headers import (
build_user_agent_usage,
build_x_amz_user_agent_usage,
)
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
from src.services.provider.adapters.kiro.models.usage_limits import (
UsageLimitsResponse,
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.rust_http import execute_kiro_rust_http_request
from src.services.provider.adapters.kiro.token_manager import (
generate_machine_id,
is_token_expired,
refresh_access_token,
)
class KiroAccountBannedException(Exception):
"""Kiro 账户被封禁异常"""
def __init__(
self,
message: str = "账户已封禁",
status_code: int = 403,
reason: str | None = None,
):
super().__init__(message)
self.message = message
self.status_code = status_code
self.reason = reason
async def fetch_kiro_usage_limits(
auth_config: dict[str, Any],
proxy_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
调用 Kiro getUsageLimits API 获取使用额度信息
Args:
auth_config: 解密后的 KiroAuthConfig 数据
proxy_config: 代理配置(可选)
Returns:
包含 usage_data 和 updated_auth_config 的字典
Raises:
RuntimeError: 请求失败时抛出
"""
cfg = KiroAuthConfig.from_dict(auth_config)
# 检查是否有缓存的 access_token 且未过期
access_token: str | None = None
updated_cfg: KiroAuthConfig | None = None
if cfg.access_token and not is_token_expired(cfg.expires_at):
# 使用缓存的 token
access_token = cfg.access_token
updated_cfg = cfg
logger.debug("[KIRO_QUOTA] 使用缓存的 access_token")
else:
# token 过期或不存在,需要刷新
logger.debug("[KIRO_QUOTA] Token 已过期或不存在,正在刷新...")
access_token, updated_cfg = await refresh_access_token(cfg, proxy_config=proxy_config)
if not access_token:
raise RuntimeError("无法获取 Kiro access_token")
# 构建请求
effective_cfg = updated_cfg or cfg
region = effective_cfg.effective_api_region()
host = f"q.{region}.amazonaws.com"
machine_id = generate_machine_id(effective_cfg)
kiro_version = (effective_cfg.kiro_version or "0.8.0").strip() or "0.8.0"
# 构建 URL添加 isEmailRequired=true 获取邮箱)
url = f"https://{host}/getUsageLimits?origin=AI_EDITOR&resourceType=AGENTIC_REQUEST&isEmailRequired=true"
profile_arn = get_profile_arn_for_payload(effective_cfg)
if profile_arn:
from urllib.parse import quote
url += f"&profileArn={quote(profile_arn, safe='')}"
logger.debug("[KIRO_QUOTA] 请求 URL: {}", url)
# 构建 headers
headers = {
"x-amz-user-agent": build_x_amz_user_agent_usage(
kiro_version=kiro_version, machine_id=machine_id
),
"User-Agent": build_user_agent_usage(kiro_version=kiro_version, machine_id=machine_id),
"host": host,
"amz-sdk-invocation-id": str(uuid.uuid4()),
"amz-sdk-request": "attempt=1; max=1",
"Authorization": f"Bearer {access_token}",
"Connection": "close",
}
response = await execute_kiro_rust_http_request(
method="GET",
url=url,
headers=headers,
body=None,
proxy_config=proxy_config,
request_id=f"kiro-usage:{region}:{machine_id}",
provider_api_format="kiro:usage",
)
if response is None:
client = await HTTPClientPool.get_proxy_client(proxy_config=proxy_config)
response = await client.get(url, headers=headers, timeout=httpx.Timeout(30.0))
if response.status_code != 200:
response_text = (response.text or "").strip()
# 检测账户封禁403/423 均视为封禁)
# 对于 Kiro403 表示账户权限被拒绝(无论是封禁、权限不足还是其他原因),
# 都应该标记为异常状态,以便管理员及时处理
is_banned = False
ban_reason = None
if response.status_code in (403, 423):
is_banned = True
# 检测封禁相关错误(用于提供更详细的原因说明)
banned_keywords = [
"AccountSuspendedException",
"account.*suspend",
"account.*banned",
"account.*disabled",
"account.*access.*denied",
]
for keyword in banned_keywords:
if re.search(keyword, response_text, re.IGNORECASE):
ban_reason = (
response_text[:200] if response_text else f"HTTP {response.status_code}"
)
break
# 如果没有匹配到特定关键词,使用通用原因
if not ban_reason:
if response.status_code == 423:
ban_reason = response_text[:200] if response_text else "HTTP 423 Locked"
else:
ban_reason = response_text[:200] if response_text else "HTTP 403 权限被拒绝"
error_msg = {
401: "认证失败Token 无效或已过期",
403: "账户异常,权限被拒绝",
423: "账户已封禁",
429: "请求过于频繁,已被限流",
}.get(response.status_code, "获取使用额度失败")
if 500 <= response.status_code < 600:
error_msg = "服务器错误AWS 服务暂时不可用"
logger.debug(
"kiro usage API error: HTTP {} | {}",
response.status_code,
response_text[:200],
)
# 如果检测到封禁,抛出带有封禁标记的异常
if is_banned:
raise KiroAccountBannedException(
message=error_msg,
status_code=response.status_code,
reason=ban_reason,
)
raise RuntimeError(f"{error_msg}: HTTP {response.status_code}")
try:
data = response.json()
except Exception as exc:
raise RuntimeError(f"获取使用额度成功但响应解析失败: HTTP {response.status_code}") from exc
# 返回刷新后的配置(用于更新 auth_config
return {
"usage_data": data,
"updated_auth_config": updated_cfg.to_dict() if updated_cfg else None,
}
def parse_kiro_usage_response(data: dict) -> dict | None:
"""
解析 Kiro getUsageLimits API 响应,提取限额信息和用户邮箱
返回格式与 kiro.rs BalanceResponse 类似:
- subscription_title: 订阅类型(如 "KIRO PRO+"
- current_usage: 当前使用量
- usage_limit: 使用限额
- remaining: 剩余额度
- usage_percentage: 使用百分比
- next_reset_at: 下次重置时间Unix 时间戳)
- email: 用户邮箱(通过 isEmailRequired=true 获取)
"""
if not data:
return None
usage_resp = UsageLimitsResponse.from_dict(data)
current_usage = calculate_current_usage(usage_resp)
usage_limit = calculate_total_usage_limit(usage_resp)
remaining = max(usage_limit - current_usage, 0.0)
usage_percentage = (current_usage / usage_limit * 100.0) if usage_limit > 0 else 0.0
usage_percentage = min(usage_percentage, 100.0)
result: dict[str, Any] = {
"current_usage": current_usage,
"usage_limit": usage_limit,
"remaining": remaining,
"usage_percentage": usage_percentage,
}
# 订阅类型
if usage_resp.subscription_info and usage_resp.subscription_info.subscription_title:
result["subscription_title"] = usage_resp.subscription_info.subscription_title
# 下次重置时间
if usage_resp.next_date_reset is not None:
result["next_reset_at"] = usage_resp.next_date_reset
elif usage_resp.usage_breakdown_list and usage_resp.usage_breakdown_list[0].next_date_reset:
result["next_reset_at"] = usage_resp.usage_breakdown_list[0].next_date_reset
# 解析用户邮箱(从 desktopUserInfo 或 userInfo 中获取)
user_info = data.get("desktopUserInfo") or data.get("userInfo") or {}
if isinstance(user_info, dict):
email = user_info.get("email")
if isinstance(email, str) and email.strip():
result["email"] = email.strip()
# 添加更新时间戳
result["updated_at"] = int(time.time())
return result
__all__ = [
"KiroAccountBannedException",
"fetch_kiro_usage_limits",
"parse_kiro_usage_response",
]

View File

@@ -0,0 +1,63 @@
"""Vertex AI 认证处理。
- Service Account: GCP SA JSON → JWT → Access Token → Bearer header
- API Key: 通过 URL ?key= 查询参数认证auth 层返回 None由 transport hook 处理)
"""
from __future__ import annotations
import json
from typing import Any
from src.core.provider_auth_types import ProviderAuthInfo
async def _auth_service_account(key: Any, endpoint: Any | None = None) -> ProviderAuthInfo:
"""Service Account 认证SA JSON → JWT → Access Token。"""
from src.core.crypto import crypto_service
from src.core.exceptions import InvalidRequestException
from src.core.vertex_auth import VertexAuthError, VertexAuthService
try:
# 优先从 auth_config 读取,兼容从 api_key 读取(过渡期)
encrypted_auth_config = getattr(key, "auth_config", None)
if encrypted_auth_config:
if isinstance(encrypted_auth_config, dict):
sa_json = encrypted_auth_config
else:
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
sa_json = json.loads(decrypted_config)
else:
# 兼容旧数据:从 api_key 读取
decrypted_key = crypto_service.decrypt(key.api_key)
if decrypted_key == "__placeholder__":
raise InvalidRequestException("认证配置丢失,请重新添加该密钥。")
sa_json = json.loads(decrypted_key)
if not isinstance(sa_json, dict):
raise InvalidRequestException("Service Account JSON 无效,请重新添加该密钥。")
# 获取 Access Token注入代理配置
from src.services.provider.auth import _get_proxy_config
from src.services.proxy_node.resolver import build_proxy_client_kwargs
effective_proxy = _get_proxy_config(key, endpoint)
service = VertexAuthService(sa_json)
access_token = await service.get_access_token(
httpx_client_kwargs=build_proxy_client_kwargs(effective_proxy, timeout=30),
)
return ProviderAuthInfo(
auth_header="Authorization",
auth_value=f"Bearer {access_token}",
decrypted_auth_config=sa_json,
)
except InvalidRequestException:
raise
except VertexAuthError as e:
raise InvalidRequestException(f"Vertex AI 认证失败:{e}")
except json.JSONDecodeError:
raise InvalidRequestException("Service Account JSON 格式无效,请重新添加该密钥。")
except Exception:
raise InvalidRequestException("Vertex AI 认证失败,请检查 Key 的 auth_config")

View File

@@ -0,0 +1,45 @@
"""Vertex AI 常量配置。
从 transport.py 迁移,集中管理 Vertex AI 模型格式映射和 region 配置。
"""
from __future__ import annotations
# Vertex AI 模型前缀到 API 格式的映射
# 用于 provider_type=vertex_ai 时,根据模型名动态确定实际的请求/响应格式
# 格式:前缀 -> endpoint signaturefamily:kind
MODEL_FORMAT_MAPPING: dict[str, str] = {
"claude-": "claude:chat", # Anthropic Claude 模型
"gemini-": "gemini:chat", # Google Gemini 模型
"imagen-": "gemini:chat", # Google Imagen 模型(使用 Gemini chat 格式)
}
# Vertex AI 默认 endpoint signature当模型前缀不匹配时
DEFAULT_FORMAT: str = "gemini:chat"
# Vertex AI 模型默认 region 映射
# 用户可以通过 auth_config.model_regions 覆盖
DEFAULT_MODEL_REGIONS: dict[str, str] = {
# Gemini 3 系列(使用 global
"gemini-3.1-pro-preview": "global",
"gemini-3-pro-image-preview": "global",
# Gemini 2.0 系列
"gemini-2.0-flash": "us-central1",
"gemini-2.0-flash-exp": "us-central1",
"gemini-2.0-flash-001": "us-central1",
"gemini-2.0-pro-exp": "us-central1",
"gemini-2.0-flash-exp-image-generation": "us-central1",
# Gemini 1.5 系列
"gemini-1.5-pro": "us-central1",
"gemini-1.5-pro-001": "us-central1",
"gemini-1.5-pro-002": "us-central1",
"gemini-1.5-flash": "us-central1",
"gemini-1.5-flash-001": "us-central1",
"gemini-1.5-flash-002": "us-central1",
# Imagen 系列
"imagen-3.0-generate-001": "us-central1",
"imagen-3.0-fast-generate-001": "us-central1",
}
# API Key 认证的全局端点
API_KEY_BASE_URL = "https://aiplatform.googleapis.com"

View File

@@ -0,0 +1,451 @@
"""Vertex AI provider plugin — 统一注册入口。
注册 Vertex AI 对各通用 registry / capability registry 的 hooks
- Transport Hook (URL 构建Gemini 走 Express modeClaude 走 Service Account)
- Model Fetcher (专用上游模型获取链路)
- Provider Format Capability跨格式支持同一 Provider 可配置 Gemini / Claude
"""
from __future__ import annotations
from typing import Any
import httpx
from src.core.logger import logger
from src.core.vertex_auth import VertexAuthError, VertexAuthService
from src.services.provider.adapters.vertex_ai.transport import get_effective_format
# Vertex AI 公共 API 根
_VERTEX_API_BASE = "https://aiplatform.googleapis.com"
_MODEL_PAGE_SIZE = 100
_MODEL_MAX_PAGES = 20
def _normalize_extra_headers(raw: Any) -> dict[str, str]:
if not isinstance(raw, dict):
return {}
return {str(k): str(v) for k, v in raw.items() if k and v is not None}
def _looks_like_service_account(auth_config: dict[str, Any] | None) -> bool:
if not isinstance(auth_config, dict):
return False
return all(
isinstance(auth_config.get(k), str) and str(auth_config.get(k)).strip()
for k in ("client_email", "private_key", "project_id")
)
def _extract_model_id(raw_name: str) -> str:
name = str(raw_name or "").strip()
if not name:
return ""
if "/models/" in name:
return name.split("/models/", 1)[-1].strip()
if name.startswith("models/"):
return name.split("models/", 1)[-1].strip()
return name
def _extract_publisher(item: dict[str, Any], fallback: str | None = None) -> str | None:
publisher = item.get("publisher")
if isinstance(publisher, str) and publisher.strip():
return publisher.strip()
raw_name = item.get("name")
if isinstance(raw_name, str) and "/publishers/" in raw_name:
try:
after = raw_name.split("/publishers/", 1)[1]
candidate = after.split("/", 1)[0].strip()
if candidate:
return candidate
except Exception:
pass
return fallback
def _extract_items(data: Any) -> list[dict[str, Any]]:
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if not isinstance(data, dict):
return []
for key in ("publisherModels", "models", "data", "items"):
value = data.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
def _parse_models_payload(
data: Any,
*,
auth_config: dict[str, Any] | None,
fallback_publisher: str | None = None,
) -> list[dict[str, Any]]:
models: list[dict[str, Any]] = []
for item in _extract_items(data):
raw_name = item.get("id") or item.get("name") or item.get("model")
if not isinstance(raw_name, str):
continue
model_id = _extract_model_id(raw_name)
if not model_id:
continue
display_name_raw = (
item.get("displayName") or item.get("display_name") or item.get("title") or model_id
)
display_name = (
str(display_name_raw).strip() if isinstance(display_name_raw, str) else model_id
)
if not display_name:
display_name = model_id
models.append(
{
"id": model_id,
"owned_by": _extract_publisher(item, fallback=fallback_publisher),
"display_name": display_name,
"api_format": get_effective_format(model_id, auth_config),
}
)
return models
def _build_google_publisher_list_url(base_url: str) -> str:
base = str(base_url or "").rstrip("/")
if not base:
base = _VERTEX_API_BASE
if base.endswith("/v1"):
return f"{base}/publishers/google/models"
if base.endswith("/v1beta"):
return f"{base}/publishers/google/models"
return f"{base}/v1/publishers/google/models"
def _iter_endpoint_base_urls(ctx: Any) -> list[str]:
seen: set[str] = set()
urls: list[str] = []
for cfg in (ctx.format_to_endpoint or {}).values():
base_url = str(getattr(cfg, "base_url", "") or "").strip()
if not base_url:
continue
norm = base_url.rstrip("/")
if norm in seen:
continue
seen.add(norm)
urls.append(norm)
if _VERTEX_API_BASE not in seen:
urls.append(_VERTEX_API_BASE)
return urls
def _get_endpoint_headers(ctx: Any, api_format: str) -> dict[str, str]:
cfg = (ctx.format_to_endpoint or {}).get(api_format)
return _normalize_extra_headers(getattr(cfg, "extra_headers", None))
def _dedupe_models(models: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[str] = set()
result: list[dict[str, Any]] = []
for model in models:
model_id = str(model.get("id", "")).strip()
api_format = str(model.get("api_format", "")).strip()
if not model_id:
continue
unique_key = f"{model_id}:{api_format}"
if unique_key in seen:
continue
seen.add(unique_key)
result.append(model)
return result
def _is_soft_not_found(error: str) -> bool:
return str(error).strip().startswith("HTTP 404:")
def _iter_regions(auth_config: dict[str, Any] | None) -> list[str]:
seen: set[str] = set()
regions: list[str] = []
def _add(raw: Any) -> None:
if not isinstance(raw, str):
return
region = raw.strip()
if not region or region in seen:
return
seen.add(region)
regions.append(region)
if isinstance(auth_config, dict):
_add(auth_config.get("region"))
model_regions = auth_config.get("model_regions")
if isinstance(model_regions, dict):
for region in model_regions.values():
_add(region)
_add("global")
_add("us-central1")
return regions
async def _fetch_models_from_url(
client: httpx.AsyncClient,
*,
url: str,
headers: dict[str, str],
params: dict[str, Any],
auth_config: dict[str, Any] | None,
fallback_publisher: str | None = None,
) -> tuple[list[dict[str, Any]], str | None, bool]:
all_models: list[dict[str, Any]] = []
next_page_token: str | None = None
has_success = False
for _ in range(_MODEL_MAX_PAGES):
req_params = dict(params)
if next_page_token:
req_params["pageToken"] = next_page_token
try:
resp = await client.get(url, headers=headers, params=req_params)
except httpx.TimeoutException:
return [], "timeout", has_success
except Exception as exc:
return [], f"request error: {exc}", has_success
if resp.status_code != 200:
body = resp.text[:500] if resp.text else "(empty)"
return [], f"HTTP {resp.status_code}: {body}", has_success
has_success = True
try:
payload = resp.json()
except Exception:
body = resp.text[:500] if resp.text else "(empty)"
return [], f"invalid json body: {body}", has_success
all_models.extend(
_parse_models_payload(
payload,
auth_config=auth_config,
fallback_publisher=fallback_publisher,
)
)
if not isinstance(payload, dict):
break
token = payload.get("nextPageToken")
next_page_token = str(token).strip() if isinstance(token, str) else None
if not next_page_token:
break
return all_models, None, has_success
async def _fetch_models_vertex_api_key(
client: httpx.AsyncClient,
*,
ctx: Any,
auth_config: dict[str, Any] | None,
) -> tuple[list[dict[str, Any]], list[str], bool]:
"""API Key 仅抓取 Vertex AI Express mode 的 Google publisher models。"""
api_key = str(ctx.api_key_value or "").strip()
if not api_key or api_key == "__placeholder__":
return [], ["vertex_ai(api_key): missing api key"], False
all_models: list[dict[str, Any]] = []
hard_errors: list[str] = []
soft_errors: list[str] = []
has_success = False
endpoint_headers = _get_endpoint_headers(ctx, "gemini:chat")
vertex_list_urls = [
_build_google_publisher_list_url(base) for base in _iter_endpoint_base_urls(ctx)
]
# Vertex Express mode list (publisher=google)
for url in vertex_list_urls:
headers = {"Accept": "application/json", **endpoint_headers}
models, err, success = await _fetch_models_from_url(
client,
url=url,
headers=headers,
params={"key": api_key, "pageSize": _MODEL_PAGE_SIZE},
auth_config=auth_config,
fallback_publisher="google",
)
if success:
has_success = True
if err:
labeled = f"{url}: {err}"
if _is_soft_not_found(err):
soft_errors.append(labeled)
else:
hard_errors.append(labeled)
continue
all_models.extend(models)
deduped = _dedupe_models(all_models)
if deduped:
return deduped, hard_errors, has_success or True
if hard_errors:
return [], hard_errors, has_success
if soft_errors:
return [], [soft_errors[0]], has_success
return [], [], has_success
async def _fetch_models_vertex_service_account(
client: httpx.AsyncClient,
*,
ctx: Any,
auth_config: dict[str, Any] | None,
client_kwargs: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[str], bool]:
"""Service Account 抓取 Vertex AI Google + Anthropic publisher models。"""
if not isinstance(auth_config, dict):
return [], ["vertex_ai(service_account): missing auth_config"], False
try:
auth_service = VertexAuthService(auth_config)
access_token = await auth_service.get_access_token(httpx_client_kwargs=client_kwargs)
except VertexAuthError as exc:
return [], [f"vertex_ai(service_account): auth failed: {exc}"], False
except Exception as exc:
return [], [f"vertex_ai(service_account): auth failed: {exc}"], False
project_id = str(auth_config.get("project_id") or "").strip()
if not project_id:
return [], ["vertex_ai(service_account): missing project_id"], False
all_models: list[dict[str, Any]] = []
hard_errors: list[str] = []
soft_errors: list[str] = []
has_success = False
gemini_headers = {"Accept": "application/json", **_get_endpoint_headers(ctx, "gemini:chat")}
claude_headers = {"Accept": "application/json", **_get_endpoint_headers(ctx, "claude:chat")}
gemini_headers["Authorization"] = f"Bearer {access_token}"
claude_headers["Authorization"] = f"Bearer {access_token}"
for region in _iter_regions(auth_config):
base = (
_VERTEX_API_BASE
if region == "global"
else f"https://{region}-aiplatform.googleapis.com"
)
requests = [
(
"google",
f"{base}/v1/projects/{project_id}/locations/{region}/publishers/google/models",
gemini_headers,
),
(
"anthropic",
f"{base}/v1/projects/{project_id}/locations/{region}/publishers/anthropic/models",
claude_headers,
),
]
for publisher, url, headers in requests:
models, err, success = await _fetch_models_from_url(
client,
url=url,
headers=headers,
params={"pageSize": _MODEL_PAGE_SIZE},
auth_config=auth_config,
fallback_publisher=publisher,
)
if success:
has_success = True
if err:
labeled = f"{url}: {err}"
if _is_soft_not_found(err):
soft_errors.append(labeled)
else:
hard_errors.append(labeled)
continue
all_models.extend(models)
deduped = _dedupe_models(all_models)
if deduped:
return deduped, hard_errors, has_success or True
if hard_errors:
return [], hard_errors, has_success
if soft_errors:
return [], [soft_errors[0]], has_success
return [], [], has_success
async def fetch_models_vertex_ai(
ctx: Any,
timeout_seconds: float,
) -> tuple[list[dict], list[str], bool, dict[str, Any] | None]:
"""Vertex AI 专用模型获取链路。
- API Key: 仅请求 Vertex AI Express mode 的 Gemini models
- Service Account: 使用 SA 凭证换取 Bearer Token按 region 查询 Gemini + Claude models
"""
from src.services.proxy_node.resolver import build_proxy_client_kwargs
auth_config = ctx.auth_config if isinstance(ctx.auth_config, dict) else None
is_service_account = _looks_like_service_account(auth_config)
client_kwargs = build_proxy_client_kwargs(ctx.proxy_config, timeout=timeout_seconds)
async with httpx.AsyncClient(**client_kwargs) as client:
if is_service_account:
models, errors, has_success = await _fetch_models_vertex_service_account(
client,
ctx=ctx,
auth_config=auth_config,
client_kwargs=client_kwargs,
)
else:
models, errors, has_success = await _fetch_models_vertex_api_key(
client,
ctx=ctx,
auth_config=auth_config,
)
if not models and errors:
logger.warning("Vertex 模型获取失败: {}", "; ".join(errors))
return models, errors, has_success, None
def register_all() -> None:
"""一次性注册 Vertex AI 的所有 hooks 到各通用 registry。"""
from src.core.api_format.capabilities import register_provider_behavior_variant
from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
from src.services.provider.adapters.vertex_ai.transport import build_vertex_ai_url
from src.services.provider.transport import register_transport_hook
# Transport: Vertex AI 同时支持 gemini:chat 和 claude:chat 格式
register_transport_hook("vertex_ai", "gemini:chat", build_vertex_ai_url)
register_transport_hook("vertex_ai", "claude:chat", build_vertex_ai_url)
# Model Fetcher: Vertex 走专用模型获取链路
UpstreamModelsFetcherRegistry.register(
provider_types=["vertex_ai"],
fetcher=fetch_models_vertex_ai,
)
# Provider Format Capability跨格式支持同一 Vertex AI Provider 可同时访问 Gemini 和 Claude 模型)
register_provider_behavior_variant("vertex_ai", cross_format=True)
__all__ = ["fetch_models_vertex_ai", "register_all"]

View File

@@ -0,0 +1,291 @@
"""Vertex AI URL 构建Transport Hook
Vertex AI Gemini / Imagen 支持两种认证路径:
- API Key + Gemini/Imagen (Express mode):
https://aiplatform.googleapis.com/v1/publishers/google/models/{model}:{action}?key={API_KEY}
- Service Account + Gemini/Imagen:
https://{region}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{region}/publishers/google/models/{model}:{action}
Claude 仍走标准 Vertex AI Service Account 路径:
- Service Account + Claude:
https://{region}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{region}/publishers/anthropic/models/{model}:{action}
"""
from __future__ import annotations
import json
from typing import Any
from urllib.parse import urlencode
from src.core.logger import logger
from src.core.provider_types import ProviderType, normalize_provider_type
from src.services.provider.adapters.vertex_ai.constants import (
API_KEY_BASE_URL,
DEFAULT_FORMAT,
DEFAULT_MODEL_REGIONS,
MODEL_FORMAT_MAPPING,
)
from src.services.provider.format import normalize_endpoint_signature
from src.services.provider.transport import looks_like_vertex_ai_host, redact_url_for_log
def is_vertex_ai_context(
*,
base_url: str | None = None,
provider_type: Any = None,
endpoint: Any = None,
key: Any = None,
) -> bool:
"""Best-effort 判断当前测试/请求上下文是否应视为 Vertex AI。"""
if normalize_provider_type(provider_type) == ProviderType.VERTEX_AI.value:
return True
for obj in (endpoint, key):
provider = getattr(obj, "provider", None) if obj is not None else None
if normalize_provider_type(getattr(provider, "provider_type", None)) == (
ProviderType.VERTEX_AI.value
):
return True
candidate_base_url = str(base_url or getattr(endpoint, "base_url", "") or "").strip()
if not candidate_base_url:
return False
endpoint_sig = str(getattr(endpoint, "api_format", "") or "").strip()
auth_type = str(getattr(key, "auth_type", "") or "").strip()
return looks_like_vertex_ai_host(candidate_base_url, endpoint_sig, auth_type)
def get_effective_format(
model: str,
auth_config: dict[str, Any] | None = None,
) -> str:
"""获取 Vertex AI 模式下模型的实际 API 格式。
优先级:
1. auth_config.model_format_mapping 中的精确匹配
2. auth_config.model_format_mapping 中的前缀匹配
3. 内置 MODEL_FORMAT_MAPPING 前缀匹配
4. auth_config.default_format
5. 内置 DEFAULT_FORMAT
"""
user_format_mapping: dict[str, str] = {}
user_default_format: str | None = None
if auth_config:
user_format_mapping = auth_config.get("model_format_mapping", {})
user_default_format = auth_config.get("default_format")
# 1. 用户配置:精确匹配
if model in user_format_mapping:
try:
return normalize_endpoint_signature(user_format_mapping[model])
except Exception:
logger.warning(
"Invalid vertex_ai model_format_mapping value for model '{}': {!r}",
model,
user_format_mapping[model],
)
# 2. 用户配置:前缀匹配
for prefix, api_format in user_format_mapping.items():
if prefix.endswith("-") and model.startswith(prefix):
try:
return normalize_endpoint_signature(api_format)
except Exception:
logger.warning(
"Invalid vertex_ai model_format_mapping value for prefix '{}': {!r}",
prefix,
api_format,
)
break
# 3. 内置配置:前缀匹配
for prefix, api_format in MODEL_FORMAT_MAPPING.items():
if model.startswith(prefix):
return normalize_endpoint_signature(api_format)
# 4. 用户默认格式
if user_default_format:
try:
return normalize_endpoint_signature(user_default_format)
except Exception:
logger.warning("Invalid vertex_ai default_format: {!r}", user_default_format)
# 5. 内置默认格式
return DEFAULT_FORMAT
def build_vertex_ai_url(
endpoint: Any,
*,
is_stream: bool,
effective_query_params: dict[str, Any],
path_params: dict[str, Any] | None = None,
key: Any = None,
decrypted_auth_config: dict[str, Any] | None = None,
) -> str:
"""Vertex AI transport hook — 统一 URL 构建入口。"""
from src.core.exceptions import InvalidRequestException
model = str((path_params or {}).get("model", "") or "").strip()
if not model:
raise InvalidRequestException("Vertex AI 请求缺少 model 参数")
auth_type = str(getattr(key, "auth_type", "api_key") or "api_key").strip().lower()
is_claude_model = model.startswith("claude-")
if auth_type == "api_key":
if is_claude_model:
raise InvalidRequestException(
"Vertex API Key 不支持 Claude 模型,请改用 Service Account 认证。"
)
return _build_api_key_url(
key=key,
path_params=path_params,
query_params=effective_query_params,
is_stream=is_stream,
)
# service_account以及向后兼容旧的 "vertex_ai" auth_type
return _build_service_account_url(
key=key,
path_params=path_params,
query_params=effective_query_params,
is_stream=is_stream,
decrypted_auth_config=decrypted_auth_config,
)
def _build_api_key_url(
key: Any,
*,
path_params: dict[str, Any] | None = None,
query_params: dict[str, Any] | None = None,
is_stream: bool = False,
) -> str:
"""构建 API Key 认证的全局端点 URL。
格式: https://aiplatform.googleapis.com/v1/publishers/google/models/{model}:{action}?key={API_KEY}
"""
from src.core.crypto import crypto_service
from src.core.exceptions import InvalidRequestException
model = (path_params or {}).get("model", "")
if not model:
raise InvalidRequestException("Vertex AI 请求缺少 model 参数")
action = "streamGenerateContent" if is_stream else "generateContent"
path = f"/v1/publishers/google/models/{model}:{action}"
url = f"{API_KEY_BASE_URL}{path}"
# 构建查询参数
params = dict(query_params) if query_params else {}
# 附加 API Key
api_key_value = crypto_service.decrypt(key.api_key) if key else ""
if api_key_value:
params["key"] = api_key_value
# Gemini 流式请求使用 SSE
if is_stream:
params.setdefault("alt", "sse")
params.pop("beta", None)
if params:
query_string = urlencode(params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
logger.debug("Vertex AI (API Key) URL: {}", redact_url_for_log(url))
return url
def _build_service_account_url(
key: Any,
*,
path_params: dict[str, Any] | None = None,
query_params: dict[str, Any] | None = None,
is_stream: bool = False,
decrypted_auth_config: dict[str, Any] | None = None,
) -> str:
"""构建 Service Account 认证的 Vertex AI 区域端点 URL。"""
from src.core.crypto import crypto_service
from src.core.exceptions import InvalidRequestException
# 优先使用传入的已解密配置,避免重复解密
auth_config: dict[str, Any] = {}
if decrypted_auth_config:
auth_config = decrypted_auth_config
else:
# 兜底:从 key.auth_config 解密(理论上不应走到这里)
raw_auth_config = getattr(key, "auth_config", None) if key else None
if raw_auth_config:
try:
if isinstance(raw_auth_config, dict):
auth_config = raw_auth_config
else:
decrypted_config = crypto_service.decrypt(raw_auth_config)
auth_config = json.loads(decrypted_config)
except Exception as e:
logger.error("解密 Vertex AI auth_config 失败: {}", e)
auth_config = {}
# 获取必需的配置
project_id = auth_config.get("project_id")
if not project_id:
raise InvalidRequestException(
"Vertex AI 配置缺少 project_id请在 Key 的 auth_config 中提供)"
)
# 获取模型名
model = (path_params or {}).get("model", "")
if not model:
raise InvalidRequestException("Vertex AI 请求缺少 model 参数")
# 确定 region优先级用户配置 > 内置默认 > 用户默认 > 兜底)
user_model_regions = auth_config.get("model_regions", {})
user_default_region = auth_config.get("region")
if model in user_model_regions:
region = user_model_regions[model]
elif model in DEFAULT_MODEL_REGIONS:
region = DEFAULT_MODEL_REGIONS[model]
elif user_default_region:
region = user_default_region
else:
region = "global"
if model.startswith("claude-"):
publisher = "anthropic"
action = "streamRawPredict" if is_stream else "rawPredict"
else:
publisher = "google"
action = "streamGenerateContent" if is_stream else "generateContent"
# 构建 URLglobal region 使用不同的 URL 格式)
if region == "global":
base_url = "https://aiplatform.googleapis.com"
else:
base_url = f"https://{region}-aiplatform.googleapis.com"
path = f"/v1/projects/{project_id}/locations/{region}/publishers/{publisher}/models/{model}:{action}"
url = f"{base_url}{path}"
# 添加查询参数
effective_query_params = dict(query_params) if query_params else {}
# Gemini 流式请求使用 SSE 格式Claude 不需要
if is_stream and not model.startswith("claude-"):
effective_query_params.setdefault("alt", "sse")
# 移除不适用于 Vertex AI 的参数
effective_query_params.pop("beta", None)
if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
logger.debug("Vertex AI (SA) URL: {} (region={})", redact_url_for_log(url), region)
return url