Files
Aether/_deprecated_py_src/services/provider/adapters/kiro/converter.py
fawney19 1d9c77522a 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)
2026-04-03 16:26:16 +08:00

614 lines
20 KiB
Python

"""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",
]