Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
.env.local
credentials.json
state.json
venv/

# IDE
.vscode/
Expand Down
13 changes: 11 additions & 2 deletions kiro/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
get_kiro_q_host,
get_aws_sso_oidc_url,
)
from kiro.utils import get_machine_fingerprint
from kiro.utils import get_machine_fingerprint, detect_kiro_agent_profile_arn


# Supported SQLite token keys (searched in priority order)
Expand Down Expand Up @@ -949,7 +949,16 @@ async def force_refresh(self) -> str:
@property
def profile_arn(self) -> Optional[str]:
"""AWS CodeWhisperer profile ARN."""
return self._profile_arn
if self._profile_arn:
return self._profile_arn

# Fallback to auto-detect from Kiro Agent IDE's profile.json
detected = detect_kiro_agent_profile_arn()
if detected:
self._profile_arn = detected
return detected

return None

@property
def region(self) -> str:
Expand Down
144 changes: 134 additions & 10 deletions kiro/converters_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
to the unified format used by converters_core.py.
"""

from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple

from loguru import logger

Expand All @@ -45,6 +45,47 @@
)


def _render_web_search_result_block(block: Any) -> str:
"""
Render a web_search_tool_result content block as readable text.

When a client echoes a prior web_search turn back to the gateway, the raw
result blocks carry the grounding the assistant used. We fold them into text
so that context survives conversion to Kiro (which has no native
web_search_tool_result representation).

Args:
block: A web_search_tool_result block (dict or Pydantic model).

Returns:
A readable summary string, or "" if there is nothing to render.
"""
content = block.get("content") if isinstance(block, dict) else getattr(block, "content", None)

# Failure case: the native error object.
if isinstance(content, dict):
if content.get("type") == "web_search_tool_result_error":
return "\n<web_search>\n(Previous web search was unavailable.)\n</web_search>\n"
return ""

if not isinstance(content, list):
return ""

lines = []
for item in content:
item_dict = item if isinstance(item, dict) else getattr(item, "__dict__", {})
title = item_dict.get("title", "")
url = item_dict.get("url", "")
snippet = item_dict.get("encrypted_content", "") or item_dict.get("snippet", "")
if title or url or snippet:
lines.append(f"- {title} ({url})\n {snippet}".rstrip())

if not lines:
return ""

return "\n<web_search>\nPrevious search results:\n" + "\n".join(lines) + "\n</web_search>\n"


def convert_anthropic_content_to_text(content: Any) -> str:
"""
Extracts text content from Anthropic message content.
Expand All @@ -53,6 +94,10 @@ def convert_anthropic_content_to_text(content: Any) -> str:
- String: "Hello, world!"
- List of content blocks: [{"type": "text", "text": "Hello"}]

Server-side web_search blocks (server_tool_use / web_search_tool_result) that
a client echoes back from a prior turn are folded into text so the search
grounding is preserved through conversion to Kiro.

Args:
content: Anthropic message content

Expand All @@ -66,10 +111,16 @@ def convert_anthropic_content_to_text(content: Any) -> str:
text_parts = []
for block in content:
if isinstance(block, dict):
if block.get("type") == "text":
block_type = block.get("type")
if block_type == "text":
text_parts.append(block.get("text", ""))
elif hasattr(block, "type") and block.type == "text":
text_parts.append(block.text)
elif block_type == "web_search_tool_result":
text_parts.append(_render_web_search_result_block(block))
elif hasattr(block, "type"):
if block.type == "text":
text_parts.append(getattr(block, "text", ""))
elif block.type == "web_search_tool_result":
text_parts.append(_render_web_search_result_block(block))
return "".join(text_parts)

return str(content) if content else ""
Expand Down Expand Up @@ -426,6 +477,55 @@ def extract_thinking_config_from_anthropic(request: AnthropicMessagesRequest) ->
return ThinkingConfig(enabled=True, budget_tokens=None)


def separate_inline_system_messages(
messages: List[AnthropicMessage],
) -> Tuple[List[str], List[AnthropicMessage]]:
"""
Separates inline ``system`` role messages from conversation turns.

Some clients (notably Claude Code) inject messages with ``role == "system"``
directly into the ``messages`` array (e.g. SessionStart hook context), even
though the Anthropic spec keeps ``system`` as a separate top-level field.

To stay consistent with the OpenAI adapter (which extracts all ``system``
messages into the system prompt, see ``convert_openai_messages_to_unified``),
we peel these inline system messages out here and return their text so the
caller can merge it into the system prompt. This keeps system instructions
out of the conversation history and avoids turning them into ``user`` turns
(which would trigger synthetic alternation placeholders downstream).

Args:
messages: List of Anthropic messages (possibly containing inline system)

Returns:
Tuple of:
- List of extracted system text fragments, in original order
- List of remaining conversation messages (user/assistant and any other
non-system roles, which are normalized later by the core layer)

Example:
>>> msgs = [
... AnthropicMessage(role="user", content="Hi"),
... AnthropicMessage(role="system", content="Hook context"),
... ]
>>> parts, convo = separate_inline_system_messages(msgs)
>>> parts
['Hook context']
>>> [m.role for m in convo]
['user']
"""
inline_system_parts: List[str] = []
conversation_messages: List[AnthropicMessage] = []

for msg in messages:
if msg.role == "system":
inline_system_parts.append(convert_anthropic_content_to_text(msg.content))
else:
conversation_messages.append(msg)

return inline_system_parts, conversation_messages


def anthropic_to_kiro(
request: AnthropicMessagesRequest, conversation_id: str, profile_arn: str
) -> dict:
Expand All @@ -435,7 +535,9 @@ def anthropic_to_kiro(
This is the main entry point for Anthropic → Kiro conversion.

Key differences from OpenAI:
- System prompt is a separate field (not in messages)
- System prompt is a separate top-level field. Inline ``system`` messages in
the ``messages`` array (sent by some clients like Claude Code) are merged
into that system prompt, matching the OpenAI adapter's behavior.
- Content can be string or list of content blocks
- Tool format uses input_schema instead of parameters

Expand All @@ -450,15 +552,37 @@ def anthropic_to_kiro(
Raises:
ValueError: If there are no messages to send
"""
# Convert messages to unified format
unified_messages = convert_anthropic_messages(request.messages)
# Separate inline system messages (e.g. Claude Code hook context) from the
# actual conversation turns. This mirrors the OpenAI adapter, which extracts
# all system messages into the system prompt rather than the history.
inline_system_parts, conversation_messages = separate_inline_system_messages(
request.messages
)

if inline_system_parts:
logger.debug(
f"Merged {len(inline_system_parts)} inline system message(s) into system prompt"
)

# Convert conversation messages to unified format
unified_messages = convert_anthropic_messages(conversation_messages)

# Convert tools to unified format
unified_tools = convert_anthropic_tools(request.tools)

# System prompt is already separate in Anthropic format!
# It can be a string or list of content blocks (for prompt caching)
# System prompt comes from the top-level field first, then any inline system
# messages are appended (preserving Claude Code's intended ordering).
system_prompt = extract_system_prompt(request.system)
if inline_system_parts:
inline_system_text = "\n\n".join(
part for part in inline_system_parts if part
)
if inline_system_text:
system_prompt = (
f"{system_prompt}\n\n{inline_system_text}"
if system_prompt
else inline_system_text
)

# Get model ID for Kiro API (normalizes + resolves hidden models)
# Pass-through principle: we normalize and send to Kiro, Kiro decides if valid
Expand All @@ -470,7 +594,7 @@ def anthropic_to_kiro(
logger.debug(
f"Converting Anthropic request: model={request.model} -> {model_id}, "
f"messages={len(unified_messages)}, tools={len(unified_tools) if unified_tools else 0}, "
f"system_prompt_length={len(system_prompt)}, "
f"system_prompt_length={len(system_prompt) if system_prompt else 0}, "
f"thinking_enabled={thinking_config.enabled}, thinking_budget={thinking_config.budget_tokens}"
)

Expand Down
38 changes: 30 additions & 8 deletions kiro/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,21 +147,43 @@ async def call_kiro_mcp_api(
try:
token = await auth_manager.get_access_token()

# EXACT headers from architecture
headers = {
"Authorization": f"Bearer {token}",
"x-amzn-codewhisperer-optout": "false",
"Content-Type": "application/json"
}
# Endpoint selection for web_search MCP:
# - kiro-cli / AWS SSO OIDC accounts must call the Amazon Q endpoint
# (https://q.{region}.amazonaws.com/mcp) and pass the profile ARN via the
# x-amzn-kiro-profile-arn header. The runtime.kiro.dev host rejects MCP
# web_search with 403 "User is not authorized" / 400 "profileArn is required".
# - Other auth types keep the original q_host behavior.
from kiro.auth import AuthType

profile_arn = getattr(auth_manager, "profile_arn", None)
is_sso = getattr(auth_manager, "auth_type", None) == AuthType.AWS_SSO_OIDC

if is_sso and profile_arn:
# Derive region from the profile ARN (arn:aws:codewhisperer:{region}:...).
arn_parts = profile_arn.split(":")
region = arn_parts[3] if len(arn_parts) > 3 and arn_parts[3] else auth_manager.region
mcp_url = f"https://q.{region}.amazonaws.com/mcp"
headers = {
"Authorization": f"Bearer {token}",
"x-amzn-kiro-profile-arn": profile_arn,
"x-amzn-codewhisperer-optout": "false",
"Content-Type": "application/x-amz-json-1.0",
}
else:
headers = {
"Authorization": f"Bearer {token}",
"x-amzn-codewhisperer-optout": "false",
"Content-Type": "application/json",
}
mcp_url = f"{auth_manager.q_host}/mcp"

mcp_url = f"{auth_manager.q_host}/mcp"
logger.debug(f"Calling MCP API: {mcp_url}")

async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(mcp_url, json=mcp_request, headers=headers)

if response.status_code != 200:
logger.error(f"MCP API error: {response.status_code}")
logger.error(f"MCP API error: {response.status_code} - {response.text[:300]}")
return None, None

mcp_response = response.json()
Expand Down
77 changes: 74 additions & 3 deletions kiro/models_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,62 @@ class ImageContentBlock(BaseModel):
source: Union[Base64ImageSource, URLImageSource]


# Union type for all content blocks (including images and thinking)
class ServerToolUseContentBlock(BaseModel):
"""
Server-side tool use block (e.g. web_search), in Anthropic format.

The gateway emits these for web_search (Path A and Path B). When a client
like Claude Code echoes the conversation back on the next turn, the assistant
message contains this block, so we must accept it on input as well.

Attributes:
type: Always "server_tool_use"
id: Tool use ID (links to the matching web_search_tool_result)
name: Server-side tool name (e.g. "web_search")
input: Tool input (e.g. {"query": "..."})
"""

type: Literal["server_tool_use"] = "server_tool_use"
id: str
name: str
input: Dict[str, Any] = {}

model_config = {"extra": "allow"}


class WebSearchToolResultContentBlock(BaseModel):
"""
web_search tool result block, in Anthropic format.

Emitted by the gateway alongside server_tool_use and echoed back by clients
on subsequent turns. The content is either a list of web_search_result items
(success) or a web_search_tool_result_error object (failure), so it is kept
permissive here.

Attributes:
type: Always "web_search_tool_result"
tool_use_id: ID linking back to the server_tool_use block
content: List of result items, an error object, or a string
"""

type: Literal["web_search_tool_result"] = "web_search_tool_result"
tool_use_id: str
content: Union[str, List[Dict[str, Any]], Dict[str, Any], None] = None

model_config = {"extra": "allow"}


# Union type for all content blocks (including images, thinking, and the
# server-side web_search blocks the gateway both emits and accepts back)
ContentBlock = Union[
TextContentBlock,
ThinkingContentBlock,
ImageContentBlock,
ToolUseContentBlock,
ToolResultContentBlock,
ToolReferenceContentBlock,
ServerToolUseContentBlock,
WebSearchToolResultContentBlock,
]


Expand All @@ -182,12 +230,28 @@ class AnthropicMessage(BaseModel):
"""
Message in Anthropic format.

Although the Anthropic spec only documents ``user`` and ``assistant`` roles
in the ``messages`` array (``system`` is a separate top-level field), some
clients (notably Claude Code) inject inline messages with non-standard roles
such as ``system`` mid-conversation. Rejecting these with a 422 would break
those clients, so the role is accepted as a free-form string here, mirroring
the OpenAI ``ChatMessage`` model.

Downstream handling (in ``anthropic_to_kiro``):
- Inline ``system`` messages are hoisted into the top-level system prompt,
matching the OpenAI adapter's behavior (keeps system instructions out of
the conversation history).
- Any other non-standard role (e.g. ``developer``) is normalized to ``user``
by ``normalize_message_roles`` in ``converters_core.py``.

Attributes:
role: Message role (user or assistant)
role: Message role. Standard values are ``user`` and ``assistant``;
inline ``system`` is hoisted to the system prompt, and other
non-standard roles are normalized to ``user`` downstream.
content: Message content (string or list of content blocks)
"""

role: Literal["user", "assistant"]
role: str
content: Union[str, List[ContentBlock]]

model_config = {"extra": "allow"}
Expand Down Expand Up @@ -568,3 +632,10 @@ class AnthropicErrorResponse(BaseModel):

type: Literal["error"] = "error"
error: AnthropicErrorDetail


# Rebuild models that use forward references in Union types.
# ToolResultContentBlock.content references "ToolReferenceContentBlock" as a string —
# Pydantic v2 needs an explicit rebuild to resolve it after all classes are defined.
ToolResultContentBlock.model_rebuild()
AnthropicMessage.model_rebuild()
Loading