mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
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:
@@ -0,0 +1,3 @@
|
||||
"""Kiro provider adapter."""
|
||||
|
||||
__all__ = []
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
613
_deprecated_py_src/services/provider/adapters/kiro/converter.py
Normal file
613
_deprecated_py_src/services/provider/adapters/kiro/converter.py
Normal 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",
|
||||
]
|
||||
121
_deprecated_py_src/services/provider/adapters/kiro/envelope.py
Normal file
121
_deprecated_py_src/services/provider/adapters/kiro/envelope.py
Normal 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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
102
_deprecated_py_src/services/provider/adapters/kiro/headers.py
Normal file
102
_deprecated_py_src/services/provider/adapters/kiro/headers.py
Normal 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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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 region(IdC 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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""AWS Event Stream parser for Kiro."""
|
||||
|
||||
from .decoder import EventStreamDecoder
|
||||
from .frame import Frame
|
||||
|
||||
__all__ = ["EventStreamDecoder", "Frame"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
133
_deprecated_py_src/services/provider/adapters/kiro/plugin.py
Normal file
133
_deprecated_py_src/services/provider/adapters/kiro/plugin.py
Normal 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"]
|
||||
148
_deprecated_py_src/services/provider/adapters/kiro/request.py
Normal file
148
_deprecated_py_src/services/provider/adapters/kiro/request.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Helpers for building Kiro generateAssistantResponse requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.services.provider.adapters.kiro.constants import KIRO_GENERATE_ASSISTANT_PATH
|
||||
from src.services.provider.adapters.kiro.context import KiroRequestContext
|
||||
from src.services.provider.adapters.kiro.converter import (
|
||||
convert_claude_messages_to_conversation_state,
|
||||
)
|
||||
from src.services.provider.adapters.kiro.headers import build_generate_assistant_headers
|
||||
from src.services.provider.adapters.kiro.models.credentials import KiroAuthConfig
|
||||
from src.services.provider.adapters.kiro.token_manager import generate_machine_id
|
||||
|
||||
|
||||
def is_kiro_thinking_enabled(request_body: dict[str, Any]) -> bool:
|
||||
thinking = request_body.get("thinking")
|
||||
if not isinstance(thinking, dict):
|
||||
return False
|
||||
ttype = str(thinking.get("type") or "").strip().lower()
|
||||
return ttype in {"enabled", "adaptive"}
|
||||
|
||||
|
||||
def build_kiro_request_context(
|
||||
request_body: dict[str, Any],
|
||||
*,
|
||||
cfg: KiroAuthConfig,
|
||||
) -> KiroRequestContext:
|
||||
return KiroRequestContext(
|
||||
region=cfg.effective_api_region(),
|
||||
machine_id=generate_machine_id(cfg),
|
||||
kiro_version=cfg.kiro_version,
|
||||
system_version=cfg.system_version,
|
||||
node_version=cfg.node_version,
|
||||
thinking_enabled=is_kiro_thinking_enabled(request_body),
|
||||
)
|
||||
|
||||
|
||||
def build_kiro_request_headers(
|
||||
cfg: KiroAuthConfig,
|
||||
*,
|
||||
access_token: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
region = cfg.effective_api_region()
|
||||
host = f"q.{region}.amazonaws.com"
|
||||
return build_generate_assistant_headers(
|
||||
host=host,
|
||||
access_token=access_token,
|
||||
machine_id=generate_machine_id(cfg),
|
||||
kiro_version=cfg.kiro_version,
|
||||
system_version=cfg.system_version,
|
||||
node_version=cfg.node_version,
|
||||
)
|
||||
|
||||
|
||||
def resolve_kiro_base_url(base_url: str, *, cfg: KiroAuthConfig) -> str:
|
||||
resolved = str(base_url or "").rstrip("/")
|
||||
region = cfg.effective_api_region()
|
||||
if "{region}" in resolved:
|
||||
resolved = resolved.replace("{region}", region)
|
||||
return resolved
|
||||
|
||||
|
||||
def build_kiro_generate_assistant_url(base_url: str, *, cfg: KiroAuthConfig) -> str:
|
||||
resolved = resolve_kiro_base_url(base_url, cfg=cfg)
|
||||
if resolved.endswith(KIRO_GENERATE_ASSISTANT_PATH):
|
||||
return resolved
|
||||
return f"{resolved}{KIRO_GENERATE_ASSISTANT_PATH}"
|
||||
|
||||
|
||||
def build_kiro_inference_config(request_body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
inference_config: dict[str, Any] = {}
|
||||
|
||||
max_tokens = request_body.get("max_tokens")
|
||||
try:
|
||||
max_tokens_i = int(max_tokens) if max_tokens is not None else 0
|
||||
except Exception:
|
||||
max_tokens_i = 0
|
||||
if max_tokens_i > 0:
|
||||
inference_config["maxTokens"] = max_tokens_i
|
||||
|
||||
temperature = request_body.get("temperature")
|
||||
try:
|
||||
temperature_f = float(temperature) if temperature is not None else None
|
||||
except Exception:
|
||||
temperature_f = None
|
||||
if temperature_f is not None and temperature_f >= 0:
|
||||
inference_config["temperature"] = temperature_f
|
||||
|
||||
top_p = request_body.get("top_p")
|
||||
try:
|
||||
top_p_f = float(top_p) if top_p is not None else None
|
||||
except Exception:
|
||||
top_p_f = None
|
||||
if top_p_f is not None and top_p_f > 0:
|
||||
inference_config["topP"] = top_p_f
|
||||
|
||||
return inference_config or None
|
||||
|
||||
|
||||
def get_profile_arn_for_payload(cfg: KiroAuthConfig) -> str | None:
|
||||
profile_arn = str(cfg.profile_arn or "").strip()
|
||||
if not profile_arn:
|
||||
return None
|
||||
|
||||
from src.services.provider.adapters.kiro.models.credentials import _normalize_auth_method
|
||||
|
||||
if _normalize_auth_method(cfg.auth_method) == "idc":
|
||||
return None
|
||||
|
||||
return profile_arn
|
||||
|
||||
|
||||
def build_kiro_request_payload(
|
||||
request_body: dict[str, Any],
|
||||
*,
|
||||
model: str,
|
||||
cfg: KiroAuthConfig,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"conversationState": convert_claude_messages_to_conversation_state(
|
||||
request_body,
|
||||
model=model,
|
||||
)
|
||||
}
|
||||
|
||||
inference_config = build_kiro_inference_config(request_body)
|
||||
if inference_config:
|
||||
payload["inferenceConfig"] = inference_config
|
||||
|
||||
profile_arn = get_profile_arn_for_payload(cfg)
|
||||
if profile_arn:
|
||||
payload["profileArn"] = profile_arn
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_kiro_generate_assistant_url",
|
||||
"build_kiro_inference_config",
|
||||
"build_kiro_request_context",
|
||||
"build_kiro_request_headers",
|
||||
"build_kiro_request_payload",
|
||||
"get_profile_arn_for_payload",
|
||||
"is_kiro_thinking_enabled",
|
||||
"resolve_kiro_base_url",
|
||||
]
|
||||
108
_deprecated_py_src/services/provider/adapters/kiro/rust_http.py
Normal file
108
_deprecated_py_src/services/provider/adapters/kiro/rust_http.py
Normal 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"]
|
||||
@@ -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",
|
||||
]
|
||||
253
_deprecated_py_src/services/provider/adapters/kiro/usage.py
Normal file
253
_deprecated_py_src/services/provider/adapters/kiro/usage.py
Normal 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 均视为封禁)
|
||||
# 对于 Kiro,403 表示账户权限被拒绝(无论是封禁、权限不足还是其他原因),
|
||||
# 都应该标记为异常状态,以便管理员及时处理
|
||||
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",
|
||||
]
|
||||
Reference in New Issue
Block a user