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
11 changes: 10 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@
credentials.json
state.json

# Local Docker Compose overrides
docker-compose.yaml
docker-compose.local.yml
docker-compose.local.yaml
compose.local.yml
compose.local.yaml

# IDE
.vscode/
.idea/
.shard/
.agents/
skills-lock.json

# Python
__pycache__/
Expand All @@ -30,4 +39,4 @@ requests/
# Testing
.pytest_cache/
.coverage
htmlcov/
htmlcov/
69 changes: 54 additions & 15 deletions kiro/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,20 +273,24 @@ def _get_raw_env_value(var_name: str, env_file: str = ".env") -> Optional[str]:
# - Some models may not be available on your Kiro plan (e.g., Opus on free tier)
# - New models released after this version won't appear here
# - Update gateway regularly to get the latest model list
FALLBACK_MODELS: List[Dict[str, str]] = [
{"modelId": "auto"},
{"modelId": "claude-sonnet-4"},
{"modelId": "claude-sonnet-4.5"},
{"modelId": "claude-sonnet-4.6"},
{"modelId": "claude-haiku-4.5"},
{"modelId": "claude-opus-4.5"},
{"modelId": "claude-opus-4.6"},
{"modelId": "claude-opus-4.7"},
{"modelId": "deepseek-3.2"},
{"modelId": "glm-5"},
{"modelId": "minimax-m2.1"},
{"modelId": "minimax-m2.5"},
{"modelId": "qwen3-coder-next"},
FALLBACK_MODELS: List[Dict] = [
# 1M context: auto router and new Claude 4.x flagship models (Kiro docs)
{"modelId": "auto", "tokenLimits": {"maxInputTokens": 1000000}},
{"modelId": "claude-sonnet-4.6", "tokenLimits": {"maxInputTokens": 1000000}},
{"modelId": "claude-opus-4.6", "tokenLimits": {"maxInputTokens": 1000000}},
{"modelId": "claude-opus-4.7", "tokenLimits": {"maxInputTokens": 1000000}},
{"modelId": "claude-opus-4.8", "tokenLimits": {"maxInputTokens": 1000000}},
# 200K context: older Claude 4.x models and Haiku (Kiro docs)
{"modelId": "claude-sonnet-4", "tokenLimits": {"maxInputTokens": 200000}},
{"modelId": "claude-sonnet-4.5", "tokenLimits": {"maxInputTokens": 200000}},
{"modelId": "claude-haiku-4.5", "tokenLimits": {"maxInputTokens": 200000}},
{"modelId": "claude-opus-4.5", "tokenLimits": {"maxInputTokens": 200000}},
# Non-Claude models
{"modelId": "deepseek-3.2", "tokenLimits": {"maxInputTokens": 128000}},
{"modelId": "qwen3-coder-next", "tokenLimits": {"maxInputTokens": 256000}},
{"modelId": "glm-5", "tokenLimits": {"maxInputTokens": 200000}},
{"modelId": "minimax-m2.1", "tokenLimits": {"maxInputTokens": 200000}},
{"modelId": "minimax-m2.5", "tokenLimits": {"maxInputTokens": 200000}},
]

# ==================================================================================================
Expand All @@ -297,7 +301,9 @@ def _get_raw_env_value(var_name: str, env_file: str = ".env") -> Optional[str]:
MODEL_CACHE_TTL: int = 3600

# Default maximum number of input tokens
DEFAULT_MAX_INPUT_TOKENS: int = 200000
# Set to 1M to match the highest-tier models (sonnet-4-6, opus-4-6/4-7/4-8) on paid plans.
# Per-model overrides in FALLBACK_MODELS take precedence over this value.
DEFAULT_MAX_INPUT_TOKENS: int = 1000000

# ==================================================================================================
# Tool Description Handling (Kiro API Limitations)
Expand Down Expand Up @@ -342,6 +348,19 @@ def _get_raw_env_value(var_name: str, env_file: str = ".env") -> Optional[str]:
# Set to DEBUG for detailed troubleshooting
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO").upper()

# ==================================================================================================
# Billing Attribution Strip
# ==================================================================================================

# Claude Code 2.1.x prepends a per-request system text block of the form
# ``x-anthropic-billing-header: cc_version=...; cc_entrypoint=...; cch=<5hex>;``
# where ``cch`` is a fresh random hex token. The Kiro gateway concatenates all
# system text blocks before forwarding, so this random prefix would invalidate
# any upstream prompt cache keyed on the prompt prefix. When enabled (default),
# the gateway strips the leading attribution line / block before forwarding.
# Disable only for A/B comparison or to debug attribution behavior.
STRIP_BILLING_HEADER: bool = os.getenv("STRIP_BILLING_HEADER", "true").lower() in ("true", "1", "yes")

# ==================================================================================================
# First Token Timeout Settings (Streaming Retry)
# ==================================================================================================
Expand Down Expand Up @@ -478,6 +497,26 @@ def _warn_timeout_configuration():
FAKE_REASONING_INITIAL_BUFFER_SIZE: int = int(os.getenv("FAKE_REASONING_INITIAL_BUFFER_SIZE", "20"))


# ==================================================================================================
# Native Thinking Settings (Kiro/Claude Adaptive Thinking)
# ==================================================================================================

# Experimental native thinking pass-through for Kiro models that expose Claude adaptive thinking.
#
# Modes:
# - "off": Disabled (default, preserves existing fake reasoning behavior)
# - "auto": Enable only when the client explicitly requests a reasoning effort
# - "force": Enable for supported models even when the client does not send an effort
KIRO_NATIVE_THINKING_MODE: str = os.getenv("KIRO_NATIVE_THINKING_MODE", "off").lower()
if KIRO_NATIVE_THINKING_MODE not in ("off", "auto", "force"):
KIRO_NATIVE_THINKING_MODE = "off"

# Claude Opus 4.8/4.7 default to omitted thinking text unless display is explicitly summarized.
KIRO_NATIVE_THINKING_DISPLAY: str = os.getenv("KIRO_NATIVE_THINKING_DISPLAY", "summarized").lower()
if KIRO_NATIVE_THINKING_DISPLAY not in ("summarized", "omitted"):
KIRO_NATIVE_THINKING_DISPLAY = "summarized"


# ==================================================================================================
# Payload Size Guard Settings
# ==================================================================================================
Expand Down
100 changes: 93 additions & 7 deletions kiro/converters_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@
to the unified format used by converters_core.py.
"""

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

from loguru import logger

from kiro.config import HIDDEN_MODELS
from kiro.config import HIDDEN_MODELS, STRIP_BILLING_HEADER
from kiro.model_resolver import get_model_id_for_kiro
from kiro.models_anthropic import (
AnthropicMessagesRequest,
Expand All @@ -39,6 +40,8 @@
UnifiedMessage,
UnifiedTool,
ThinkingConfig,
build_native_thinking_config,
reasoning_effort_to_budget,
build_kiro_payload,
extract_text_content,
extract_images_from_content,
Expand Down Expand Up @@ -75,6 +78,32 @@ def convert_anthropic_content_to_text(content: Any) -> str:
return str(content) if content else ""


_BILLING_HEADER_LINE_PATTERN = re.compile(
r"^x-anthropic-billing-header:[^\n]*\n?", re.IGNORECASE
)


def _strip_billing_attribution(text: str) -> str:
"""
Remove Claude Code's per-request billing attribution string.

Claude Code (2.1.x) prepends a line like
``x-anthropic-billing-header: cc_version=...; cc_entrypoint=...; cch=<5hex>;``
to the system prompt content. The ``cch`` segment is a fresh random hex per
request, which defeats any prompt cache keyed on the prompt prefix on
upstreams that do not understand the attribution header.

The line carries no semantic content for the model and is safe to remove
before forwarding to Kiro.
"""
if not text or not STRIP_BILLING_HEADER:
return text
stripped = _BILLING_HEADER_LINE_PATTERN.sub("", text, count=1)
if stripped != text:
return stripped.lstrip("\n")
return text


def extract_system_prompt(system: Any) -> str:
"""
Extracts system prompt text from Anthropic system field.
Expand All @@ -86,6 +115,12 @@ def extract_system_prompt(system: Any) -> str:
The second format is used for prompt caching with cache_control.
We extract only the text, ignoring cache_control (not supported by Kiro).

Claude Code injects a per-request billing attribution block as the first
system text block (``x-anthropic-billing-header: ...; cch=<5hex>;``). The
random ``cch`` segment changes on every request and would invalidate any
upstream prompt cache keyed on the prompt prefix, so we drop attribution
blocks (and any attribution prefix on string-form prompts) here.

Args:
system: System prompt in string or list format

Expand All @@ -96,18 +131,25 @@ def extract_system_prompt(system: Any) -> str:
return ""

if isinstance(system, str):
return system
return _strip_billing_attribution(system)

if isinstance(system, list):
text_parts = []
for block in system:
text: Optional[str] = None
if isinstance(block, dict):
# Handle {"type": "text", "text": "...", "cache_control": {...}}
if block.get("type") == "text":
text_parts.append(block.get("text", ""))
text = block.get("text", "")
elif hasattr(block, "type") and block.type == "text":
# Handle Pydantic model
text_parts.append(getattr(block, "text", ""))
text = getattr(block, "text", "")
if text is None:
continue
# Drop pure-attribution blocks; strip leading attribution line
# from mixed blocks (defensive).
stripped = _strip_billing_attribution(text)
if not stripped.strip():
continue
text_parts.append(stripped)
return "\n".join(text_parts)

return str(system)
Expand Down Expand Up @@ -376,6 +418,7 @@ def extract_thinking_config_from_anthropic(request: AnthropicMessagesRequest) ->

Handles thinking parameter:
- {"type": "enabled", "budget_tokens": N} → enabled with budget
- {"type": "adaptive", "effort": "max"} → enabled with effort-based budget
- {"type": "disabled"} → disabled
- None → enabled with default budget

Expand All @@ -400,6 +443,11 @@ def extract_thinking_config_from_anthropic(request: AnthropicMessagesRequest) ->
>>> request.thinking = {"type": "enabled", "budget_tokens": 8000}
>>> extract_thinking_config_from_anthropic(request)
ThinkingConfig(enabled=True, budget_tokens=8000)

>>> # Adaptive effort translated to gateway fake thinking budget
>>> request.thinking = {"type": "adaptive", "effort": "max"}
>>> extract_thinking_config_from_anthropic(request)
ThinkingConfig(enabled=True, budget_tokens=4096)
"""
if not request.thinking:
# No thinking specified → use defaults
Expand All @@ -422,6 +470,31 @@ def extract_thinking_config_from_anthropic(request: AnthropicMessagesRequest) ->
logger.debug(f"Extracted thinking config from Anthropic: type='enabled', budget={budget}")
return ThinkingConfig(enabled=True, budget_tokens=budget)

if thinking_type == "adaptive":
effort = request.thinking.get("effort")
if not effort:
logger.debug("Extracted adaptive thinking config from Anthropic without effort")
return ThinkingConfig(enabled=True, budget_tokens=None)

if effort == "none":
logger.debug("Extracted adaptive thinking config from Anthropic: effort='none'")
return ThinkingConfig(enabled=False, budget_tokens=None)

try:
budget = reasoning_effort_to_budget(request.max_tokens, effort)
except ValueError:
logger.warning(
f"Unsupported Anthropic adaptive thinking effort '{effort}'. "
"Using default fake thinking budget."
)
return ThinkingConfig(enabled=True, budget_tokens=None)

logger.debug(
f"Extracted adaptive thinking config from Anthropic: effort='{effort}', "
f"max_tokens={request.max_tokens}, budget={budget}"
)
return ThinkingConfig(enabled=True, budget_tokens=budget)

# Unknown type → use defaults
return ThinkingConfig(enabled=True, budget_tokens=None)

Expand Down Expand Up @@ -466,12 +539,24 @@ def anthropic_to_kiro(

# Extract thinking configuration from thinking parameter
thinking_config = extract_thinking_config_from_anthropic(request)
native_effort: Optional[str] = None
native_display: Optional[str] = None
if isinstance(request.thinking, dict) and request.thinking.get("type") == "adaptive":
native_effort = request.thinking.get("effort") or "high"
native_display = request.thinking.get("display")
native_thinking_config = build_native_thinking_config(model_id, native_effort)
if native_display in ("summarized", "omitted"):
native_thinking_config.display = native_display
if native_thinking_config.enabled:
# Native adaptive thinking supersedes fake tag injection for this request.
thinking_config = ThinkingConfig(enabled=False, budget_tokens=None)

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"thinking_enabled={thinking_config.enabled}, thinking_budget={thinking_config.budget_tokens}"
f"thinking_enabled={thinking_config.enabled}, thinking_budget={thinking_config.budget_tokens}, "
f"native_thinking_enabled={native_thinking_config.enabled}, native_effort={native_thinking_config.effort}"
)

# Use core function to build payload
Expand All @@ -483,6 +568,7 @@ def anthropic_to_kiro(
conversation_id=conversation_id,
profile_arn=profile_arn,
thinking_config=thinking_config,
native_thinking_config=native_thinking_config,
)

return result.payload
Loading