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
21 changes: 16 additions & 5 deletions kiro/converters_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,10 @@ def convert_anthropic_tools(
"""
Converts Anthropic tools to unified format.

Silently skips Anthropic built-in server tools (web_search, code_execution,
bash, text_editor, etc.) that have no input_schema, since the Kiro API
cannot handle them.

Args:
tools: List of Anthropic tools

Expand All @@ -356,11 +360,18 @@ def convert_anthropic_tools(
if isinstance(tool, dict):
name = tool.get("name", "")
description = tool.get("description")
input_schema = tool.get("input_schema", {})
input_schema = tool.get("input_schema")
tool_type = tool.get("type")
else:
name = tool.name
description = tool.description
input_schema = tool.input_schema
name = getattr(tool, "name", "") or ""
description = getattr(tool, "description", None)
input_schema = getattr(tool, "input_schema", None)
tool_type = getattr(tool, "type", None)

# Skip built-in server tools (no input_schema) — Kiro API can't handle them
if input_schema is None:
logger.debug(f"Skipping server tool '{name or tool_type}' (no input_schema)")
continue

unified_tools.append(
UnifiedTool(name=name, description=description, input_schema=input_schema)
Expand Down Expand Up @@ -424,4 +435,4 @@ def anthropic_to_kiro(
inject_thinking=True,
)

return result.payload
return result
82 changes: 43 additions & 39 deletions kiro/converters_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
to convert their formats to Kiro API format.
"""

import hashlib
import json
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
Expand Down Expand Up @@ -93,9 +94,11 @@ class KiroPayloadResult:
Attributes:
payload: The complete Kiro API payload
tool_documentation: Documentation for tools with long descriptions (to add to system prompt)
tool_name_mapping: Mapping of truncated tool names back to originals (short→original)
"""
payload: Dict[str, Any]
tool_documentation: str = ""
tool_name_mapping: Dict[str, str] = field(default_factory=dict)


# ==================================================================================================
Expand Down Expand Up @@ -491,46 +494,34 @@ def process_tools_with_long_descriptions(
return processed_tools if processed_tools else None, tool_documentation


def validate_tool_names(tools: Optional[List[UnifiedTool]]) -> None:
TOOL_NAME_MAX_LENGTH = 64


def _make_short_name(name: str) -> str:
"""Deterministically shorten a tool name to fit within TOOL_NAME_MAX_LENGTH."""
suffix = hashlib.md5(name.encode()).hexdigest()[:8]
return name[:TOOL_NAME_MAX_LENGTH - 9] + "_" + suffix


def truncate_tool_names(
tools: Optional[List[UnifiedTool]],
) -> Dict[str, str]:
"""
Validates tool names against Kiro API 64-character limit.

Logs WARNING for each problematic tool and raises ValueError
with complete list of violations.

Args:
tools: List of tools to validate

Raises:
ValueError: If any tool name exceeds 64 characters

Example:
>>> validate_tool_names([UnifiedTool(name="short_name", description="test")])
# No error
>>> validate_tool_names([UnifiedTool(name="a" * 70, description="test")])
# Raises ValueError with detailed message
Truncate tool names exceeding 64 characters in-place.

Returns a mapping {short_name: original_name} for names that were changed.
"""
if not tools:
return
problematic_tools = []
return {}

mapping: Dict[str, str] = {}
for tool in tools:
if len(tool.name) > 64:
problematic_tools.append((tool.name, len(tool.name)))

if problematic_tools:
# Build detailed error message for client (no logging here - routes will log)
tool_list = "\n".join([
f" - '{name}' ({length} characters)"
for name, length in problematic_tools
])

raise ValueError(
f"Tool name(s) exceed Kiro API limit of 64 characters:\n"
f"{tool_list}\n\n"
f"Solution: Use shorter tool names (max 64 characters).\n"
f"Example: 'get_user_data' instead of 'get_authenticated_user_profile_data_with_extended_information_about_it'"
)
if len(tool.name) > TOOL_NAME_MAX_LENGTH:
short = _make_short_name(tool.name)
mapping[short] = tool.name
logger.debug(f"Truncated tool name '{tool.name}' -> '{short}'")
tool.name = short
return mapping


def convert_tools_to_kiro_format(tools: Optional[List[UnifiedTool]]) -> List[Dict[str, Any]]:
Expand Down Expand Up @@ -1370,8 +1361,10 @@ def build_kiro_payload(
# Process tools with long descriptions
processed_tools, tool_documentation = process_tools_with_long_descriptions(tools)

# Validate tool names against Kiro API 64-character limit
validate_tool_names(processed_tools)
# Truncate tool names that exceed 64-character Kiro API limit
tool_name_mapping = truncate_tool_names(processed_tools)
if tool_name_mapping:
logger.info(f"Truncated {len(tool_name_mapping)} tool name(s) exceeding {TOOL_NAME_MAX_LENGTH} chars")

# Add tool documentation to system prompt if present
full_system_prompt = system_prompt
Expand Down Expand Up @@ -1429,6 +1422,17 @@ def build_kiro_payload(

history = build_kiro_history(history_messages, model_id)

# Apply tool name truncation to tool_use blocks in history
if tool_name_mapping:
reverse = {v: k for k, v in tool_name_mapping.items()}
for entry in history:
arm = entry.get("assistantResponseMessage")
if arm:
for tu in arm.get("toolUses", []):
orig = tu.get("name", "")
if orig in reverse:
tu["name"] = reverse[orig]

# Current message (the last one)
current_message = merged_messages[-1]
current_content = extract_text_content(current_message.content)
Expand Down Expand Up @@ -1519,4 +1523,4 @@ def build_kiro_payload(
if profile_arn:
payload["profileArn"] = profile_arn

return KiroPayloadResult(payload=payload, tool_documentation=tool_documentation)
return KiroPayloadResult(payload=payload, tool_documentation=tool_documentation, tool_name_mapping=tool_name_mapping)
2 changes: 1 addition & 1 deletion kiro/converters_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,4 +345,4 @@ def build_kiro_payload(
inject_thinking=True
)

return result.payload
return result
16 changes: 12 additions & 4 deletions kiro/models_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,15 +185,23 @@ class AnthropicTool(BaseModel):
"""
Tool definition in Anthropic format.

Supports both custom tools (with input_schema) and Anthropic built-in
server tools like web_search, code_execution, bash, text_editor
(which have a type field but no input_schema).

Attributes:
name: Tool name (must match pattern ^[a-zA-Z0-9_-]{1,64}$)
description: Tool description (optional but recommended)
input_schema: JSON Schema for tool parameters
input_schema: JSON Schema for tool parameters (required for custom tools, absent for server tools)
type: Tool type identifier for built-in tools (e.g. "web_search_20250305")
"""

name: str
model_config = {"extra": "allow"}

name: Optional[str] = None
description: Optional[str] = None
input_schema: Dict[str, Any]
input_schema: Optional[Dict[str, Any]] = None
type: Optional[str] = None


class ToolChoiceAuto(BaseModel):
Expand Down Expand Up @@ -263,7 +271,7 @@ class AnthropicMessagesRequest(BaseModel):

model: str
messages: List[AnthropicMessage] = Field(min_length=1)
max_tokens: int
max_tokens: int = 4096

# Optional parameters - system can be string or list of content blocks
system: Optional[SystemPrompt] = None
Expand Down
78 changes: 69 additions & 9 deletions kiro/routes_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
)
from kiro.http_client import KiroHttpClient
from kiro.utils import generate_conversation_id
from kiro.tokenizer import count_tools_tokens
from kiro.tokenizer import count_tokens

# Import debug_logger
try:
Expand Down Expand Up @@ -257,11 +257,13 @@ async def messages(
profile_arn_for_payload = auth_manager.profile_arn

try:
kiro_payload = anthropic_to_kiro(
kiro_result = anthropic_to_kiro(
request_data,
conversation_id,
profile_arn_for_payload
)
kiro_payload = kiro_result.payload
tool_name_mapping = kiro_result.tool_name_mapping
except ValueError as e:
logger.error(f"Conversion error: {e}")
return JSONResponse(
Expand Down Expand Up @@ -298,10 +300,11 @@ async def messages(
shared_client = request.app.state.http_client
http_client = KiroHttpClient(auth_manager, shared_client=shared_client)

# Prepare data for token counting
# Convert Pydantic models to dicts for tokenizer
messages_for_tokenizer = [msg.model_dump() for msg in request_data.messages]
tools_for_tokenizer = [tool.model_dump() for tool in request_data.tools] if request_data.tools else None
# Count prompt tokens from the full Kiro payload (system prompt + messages + tools)
kiro_payload_prompt_tokens = count_tokens(
kiro_request_body.decode('utf-8', errors='ignore'),
apply_claude_correction=False
)

try:
# Make request to Kiro API (for both streaming and non-streaming modes)
Expand Down Expand Up @@ -368,7 +371,8 @@ async def stream_wrapper():
request_data.model,
model_cache,
auth_manager,
request_messages=messages_for_tokenizer
prompt_tokens=kiro_payload_prompt_tokens,
tool_name_mapping=tool_name_mapping
):
yield chunk
except GeneratorExit:
Expand Down Expand Up @@ -415,7 +419,8 @@ async def stream_wrapper():
request_data.model,
model_cache,
auth_manager,
request_messages=messages_for_tokenizer
prompt_tokens=kiro_payload_prompt_tokens,
tool_name_mapping=tool_name_mapping
)

await http_client.close()
Expand Down Expand Up @@ -449,4 +454,59 @@ async def stream_wrapper():
"message": f"Internal Server Error: {str(e)}"
}
}
)
)


@router.post("/v1/messages/count_tokens", dependencies=[Depends(verify_anthropic_api_key)])
async def count_tokens_endpoint(
request: Request,
request_data: AnthropicMessagesRequest,
):
"""
Anthropic Count Tokens API endpoint.

Returns estimated token count for the given request payload.
Used by Claude Code to decide when to trigger conversation compaction.

Builds the full Kiro payload and counts tokens on the serialized JSON,
consistent with the token counting approach used in the messages endpoint.
"""
logger.info(f"Request to /v1/messages/count_tokens (model={request_data.model}, messages={len(request_data.messages)})")

auth_manager: KiroAuthManager = request.app.state.auth_manager

# Build Kiro payload (same as messages endpoint)
conversation_id = generate_conversation_id()
profile_arn_for_payload = ""
if auth_manager.auth_type == AuthType.KIRO_DESKTOP and auth_manager.profile_arn:
profile_arn_for_payload = auth_manager.profile_arn

try:
kiro_payload = anthropic_to_kiro(
request_data,
conversation_id,
profile_arn_for_payload
).payload
except ValueError as e:
logger.error(f"Conversion error in count_tokens: {e}")
return JSONResponse(
status_code=400,
content={
"type": "error",
"error": {
"type": "invalid_request_error",
"message": str(e)
}
}
)

# Count tokens from the full serialized Kiro payload (same as messages endpoint)
kiro_request_body = json.dumps(kiro_payload, ensure_ascii=False, indent=2)
input_tokens = count_tokens(kiro_request_body, apply_claude_correction=False)

logger.info(f"Token count estimate: {input_tokens} (payload size: {len(kiro_request_body)} chars)")

return JSONResponse(content={
"input_tokens": input_tokens,
"context_management": {"original_input_tokens": input_tokens}
})
23 changes: 14 additions & 9 deletions kiro/routes_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from kiro.streaming_openai import stream_kiro_to_openai, collect_stream_response, stream_with_first_token_retry
from kiro.http_client import KiroHttpClient
from kiro.utils import generate_conversation_id
from kiro.tokenizer import count_tokens

# Import debug_logger
try:
Expand Down Expand Up @@ -240,11 +241,13 @@ async def chat_completions(request: Request, request_data: ChatCompletionRequest
profile_arn_for_payload = auth_manager.profile_arn

try:
kiro_payload = build_kiro_payload(
kiro_result = build_kiro_payload(
request_data,
conversation_id,
profile_arn_for_payload
)
kiro_payload = kiro_result.payload
tool_name_mapping = kiro_result.tool_name_mapping
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))

Expand Down Expand Up @@ -324,10 +327,12 @@ async def chat_completions(request: Request, request_data: ChatCompletionRequest
}
)

# Prepare data for fallback token counting
# Convert Pydantic models to dicts for tokenizer
messages_for_tokenizer = [msg.model_dump() for msg in request_data.messages]
tools_for_tokenizer = [tool.model_dump() for tool in request_data.tools] if request_data.tools else None
# Count prompt tokens from the full Kiro payload (system prompt + messages + tools)
# This matches what actually gets sent to the API, giving accurate token counts
kiro_payload_prompt_tokens = count_tokens(
kiro_request_body.decode('utf-8', errors='ignore'),
apply_claude_correction=False
)

if request_data.stream:
# Streaming mode
Expand All @@ -341,8 +346,8 @@ async def stream_wrapper():
request_data.model,
model_cache,
auth_manager,
request_messages=messages_for_tokenizer,
request_tools=tools_for_tokenizer
prompt_tokens=kiro_payload_prompt_tokens,
tool_name_mapping=tool_name_mapping
):
yield chunk
except GeneratorExit:
Expand Down Expand Up @@ -387,8 +392,8 @@ async def stream_wrapper():
request_data.model,
model_cache,
auth_manager,
request_messages=messages_for_tokenizer,
request_tools=tools_for_tokenizer
prompt_tokens=kiro_payload_prompt_tokens,
tool_name_mapping=tool_name_mapping
)

await http_client.close()
Expand Down
Loading