From ebc4c973bb00eeda8835c2a3142b70ce650d5170 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 30 Jan 2026 19:52:37 -0500 Subject: [PATCH 01/38] feat: add JSON response format support and dynamic model fetching - Add response_format parameter for OpenAI-compatible JSON mode - Add ModelService for dynamic model fetching from Anthropic API - Add claude-opus-4-5-20251101 model to supported models - Add JSON extraction and enforcement methods to MessageAdapter - Update docker-compose.yml to use published image - Bump version to 2.3.0 --- docker-compose.yml | 26 ++- pyproject.toml | 2 +- src/__init__.py | 2 +- src/constants.py | 3 +- src/main.py | 125 ++++++++++--- src/message_adapter.py | 121 ++++++++++++ src/model_service.py | 141 ++++++++++++++ src/models.py | 13 ++ src/parameter_validator.py | 27 ++- tests/test_json_format_unit.py | 305 +++++++++++++++++++++++++++++++ tests/test_model_service_unit.py | 255 ++++++++++++++++++++++++++ 11 files changed, 987 insertions(+), 33 deletions(-) create mode 100644 src/model_service.py create mode 100644 tests/test_json_format_unit.py create mode 100644 tests/test_model_service_unit.py diff --git a/docker-compose.yml b/docker-compose.yml index 6d0d141..95d993d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,34 @@ -version: '3' +version: '3.8' services: claude-wrapper: - build: . + image: ttlequals0/claude-code-openai-wrapper:latest + container_name: claude-wrapper ports: - "8000:8000" volumes: + # Mount Claude CLI credentials - ~/.claude:/root/.claude # Optional: Mount a specific workspace directory - # Uncomment and modify the line below to use a custom workspace # - ./workspace:/workspace environment: - PORT=8000 + - MAX_TIMEOUT=600000 + # Authentication (choose one method): + # Option 1: Direct API key (recommended) + # - ANTHROPIC_API_KEY=your-api-key + # Option 2: Explicit auth method selection + # - CLAUDE_AUTH_METHOD=cli # Options: cli, api_key, bedrock, vertex # Optional: Set Claude's working directory (defaults to isolated temp dir) - # Uncomment and modify the line below to set a custom working directory # - CLAUDE_CWD=/workspace + # Optional: Enable debug logging + # - DEBUG_MODE=true + # Optional: Rate limiting configuration + # - RATE_LIMIT_ENABLED=true + # - RATE_LIMIT_CHAT_PER_MINUTE=10 + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s diff --git a/pyproject.toml b/pyproject.toml index e0cc381..dcc6fe5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "claude-code-openai-wrapper" -version = "2.2.0" +version = "2.3.0" description = "OpenAI API-compatible wrapper for Claude Code" authors = ["Richard Atkinson "] readme = "README.md" diff --git a/src/__init__.py b/src/__init__.py index ca47b3b..4642a13 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ """Claude Code OpenAI Wrapper - A FastAPI-based OpenAI-compatible API for Claude Code.""" -__version__ = "2.2.0" +__version__ = "2.3.0" diff --git a/src/constants.py b/src/constants.py index 5fb452b..5eb4149 100644 --- a/src/constants.py +++ b/src/constants.py @@ -70,7 +70,8 @@ async def chat_endpoint(): ... # NOTE: Claude Agent SDK only supports Claude 4+ models, not Claude 3.x CLAUDE_MODELS = [ # Claude 4.5 Family (Latest - Fall 2025) - RECOMMENDED - "claude-opus-4-5-20250929", # Latest Opus 4.5 - Most capable + "claude-opus-4-5-20251101", # Latest Opus 4.5 - Most capable (November 2025) + "claude-opus-4-5-20250929", # Opus 4.5 - September version "claude-sonnet-4-5-20250929", # Recommended - best coding model "claude-haiku-4-5-20251001", # Fast & cheap # Claude 4.1 diff --git a/src/main.py b/src/main.py index 4a74aa4..eb1b286 100644 --- a/src/main.py +++ b/src/main.py @@ -52,6 +52,7 @@ rate_limit_endpoint, ) from src.constants import CLAUDE_MODELS, CLAUDE_TOOLS, DEFAULT_ALLOWED_TOOLS +from src.model_service import model_service # Load environment variables load_dotenv() @@ -133,6 +134,9 @@ async def lifespan(app: FastAPI): """Verify Claude Code authentication and CLI on startup.""" logger.info("Verifying Claude Code authentication and CLI...") + # Initialize model service (fetch models from API or use fallback) + await model_service.initialize() + # Validate authentication first auth_valid, auth_info = validate_claude_code_auth() @@ -197,6 +201,9 @@ async def lifespan(app: FastAPI): logger.info("Shutting down session manager...") session_manager.shutdown() + # Shutdown model service + await model_service.shutdown() + # Create FastAPI app app = FastAPI( @@ -410,6 +417,16 @@ async def generate_streaming_response( system_prompt = sampling_instructions logger.debug(f"Added sampling instructions: {sampling_instructions}") + # Check for JSON mode + json_mode = request.response_format and request.response_format.type == "json_object" + if json_mode: + # Prepend JSON instruction to system prompt + if system_prompt: + system_prompt = f"{MessageAdapter.JSON_MODE_INSTRUCTION}\n\n{system_prompt}" + else: + system_prompt = MessageAdapter.JSON_MODE_INSTRUCTION + logger.info("JSON mode enabled (streaming) - response will be accumulated and formatted") + # Filter content for unsupported features prompt = MessageAdapter.filter_content(prompt) if system_prompt: @@ -443,6 +460,7 @@ async def generate_streaming_response( chunks_buffer = [] role_sent = False # Track if we've sent the initial role chunk content_sent = False # Track if we've sent any content + json_mode_buffer = [] # Buffer for JSON mode - accumulate all content async for chunk in claude_cli.run_completion( prompt=prompt, @@ -501,15 +519,42 @@ async def generate_streaming_response( filtered_text = MessageAdapter.filter_content(raw_text) if filtered_text and not filtered_text.isspace(): + if json_mode: + # In JSON mode, buffer content for later processing + json_mode_buffer.append(filtered_text) + else: + # Create streaming chunk + stream_chunk = ChatCompletionStreamResponse( + id=request_id, + model=request.model, + choices=[ + StreamChoice( + index=0, + delta={"content": filtered_text}, + finish_reason=None, + ) + ], + ) + + yield f"data: {stream_chunk.model_dump_json()}\n\n" + content_sent = True + + elif isinstance(content, str): + # Filter out tool usage and thinking blocks + filtered_content = MessageAdapter.filter_content(content) + + if filtered_content and not filtered_content.isspace(): + if json_mode: + # In JSON mode, buffer content for later processing + json_mode_buffer.append(filtered_content) + else: # Create streaming chunk stream_chunk = ChatCompletionStreamResponse( id=request_id, model=request.model, choices=[ StreamChoice( - index=0, - delta={"content": filtered_text}, - finish_reason=None, + index=0, delta={"content": filtered_content}, finish_reason=None ) ], ) @@ -517,24 +562,38 @@ async def generate_streaming_response( yield f"data: {stream_chunk.model_dump_json()}\n\n" content_sent = True - elif isinstance(content, str): - # Filter out tool usage and thinking blocks - filtered_content = MessageAdapter.filter_content(content) - - if filtered_content and not filtered_content.isspace(): - # Create streaming chunk - stream_chunk = ChatCompletionStreamResponse( - id=request_id, - model=request.model, - choices=[ - StreamChoice( - index=0, delta={"content": filtered_content}, finish_reason=None - ) - ], + # Handle JSON mode: emit accumulated content as single JSON-formatted chunk + if json_mode and json_mode_buffer: + # Send role chunk first if not sent + if not role_sent: + initial_chunk = ChatCompletionStreamResponse( + id=request_id, + model=request.model, + choices=[ + StreamChoice( + index=0, delta={"role": "assistant", "content": ""}, finish_reason=None ) + ], + ) + yield f"data: {initial_chunk.model_dump_json()}\n\n" + role_sent = True - yield f"data: {stream_chunk.model_dump_json()}\n\n" - content_sent = True + # Combine buffered content and enforce JSON format + combined_content = "".join(json_mode_buffer) + json_content = MessageAdapter.enforce_json_format(combined_content, strict=True) + + # Emit as single chunk + json_chunk = ChatCompletionStreamResponse( + id=request_id, + model=request.model, + choices=[ + StreamChoice( + index=0, delta={"content": json_content}, finish_reason=None + ) + ], + ) + yield f"data: {json_chunk.model_dump_json()}\n\n" + content_sent = True # Handle case where no role was sent (send at least role chunk) if not role_sent: @@ -553,13 +612,16 @@ async def generate_streaming_response( # If we sent role but no content, send a minimal response if role_sent and not content_sent: + fallback_content = ( + "[]" if json_mode else "I'm unable to provide a response at the moment." + ) fallback_chunk = ChatCompletionStreamResponse( id=request_id, model=request.model, choices=[ StreamChoice( index=0, - delta={"content": "I'm unable to provide a response at the moment."}, + delta={"content": fallback_content}, finish_reason=None, ) ], @@ -672,6 +734,19 @@ async def chat_completions( system_prompt = sampling_instructions logger.debug(f"Added sampling instructions: {sampling_instructions}") + # Check for JSON mode + json_mode = ( + request_body.response_format + and request_body.response_format.type == "json_object" + ) + if json_mode: + # Prepend JSON instruction to system prompt + if system_prompt: + system_prompt = f"{MessageAdapter.JSON_MODE_INSTRUCTION}\n\n{system_prompt}" + else: + system_prompt = MessageAdapter.JSON_MODE_INSTRUCTION + logger.info("JSON mode enabled - response will be enforced as valid JSON") + # Filter content prompt = MessageAdapter.filter_content(prompt) if system_prompt: @@ -724,6 +799,12 @@ async def chat_completions( # Filter out tool usage and thinking blocks assistant_content = MessageAdapter.filter_content(raw_assistant_content) + # Enforce JSON format if JSON mode is enabled + if json_mode: + assistant_content = MessageAdapter.enforce_json_format( + assistant_content, strict=True + ) + # Add assistant response to session if using session mode if actual_session_id: assistant_message = Message(role="assistant", content=assistant_content) @@ -864,12 +945,12 @@ async def list_models( # Check FastAPI API key if configured await verify_api_key(request, credentials) - # Use constants for single source of truth + # Use dynamic models from model_service (fetched from API or fallback to constants) return { "object": "list", "data": [ {"id": model_id, "object": "model", "owned_by": "anthropic"} - for model_id in CLAUDE_MODELS + for model_id in model_service.get_models() ], } diff --git a/src/message_adapter.py b/src/message_adapter.py index 1c9d732..3f26661 100644 --- a/src/message_adapter.py +++ b/src/message_adapter.py @@ -1,11 +1,132 @@ from typing import List, Optional, Dict, Any from src.models import Message import re +import json class MessageAdapter: """Converts between OpenAI message format and Claude Code prompts.""" + # Instruction to prepend to system prompt for JSON mode + JSON_MODE_INSTRUCTION = ( + "CRITICAL: Respond with ONLY valid JSON. " + "No explanations, no markdown, no code blocks. " + "Start with [ or { and end with ] or }." + ) + + @staticmethod + def extract_json(content: str) -> Optional[str]: + """ + Extract JSON from content. + + Handles: + 1. Pure JSON (content is already valid JSON) + 2. Markdown code blocks (```json ... ```) + 3. Embedded JSON (JSON within other text) + + Args: + content: The content to extract JSON from + + Returns: + Extracted JSON string, or None if no valid JSON found + """ + if not content: + return None + + content = content.strip() + + # Case 1: Try parsing as pure JSON first + try: + json.loads(content) + return content + except json.JSONDecodeError: + pass + + # Case 2: Extract from markdown code blocks + # Match ```json ... ``` or ``` ... ``` + code_block_patterns = [ + r"```json\s*([\s\S]*?)\s*```", # ```json block + r"```\s*([\s\S]*?)\s*```", # generic ``` block + ] + + for pattern in code_block_patterns: + matches = re.findall(pattern, content, re.IGNORECASE) + for match in matches: + match = match.strip() + try: + json.loads(match) + return match + except json.JSONDecodeError: + continue + + # Case 3: Find embedded JSON (objects or arrays) + # Look for JSON objects {...} + object_pattern = r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}" + for match in re.finditer(object_pattern, content): + candidate = match.group() + try: + json.loads(candidate) + return candidate + except json.JSONDecodeError: + continue + + # Look for JSON arrays [...] + array_pattern = r"\[[^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*\]" + for match in re.finditer(array_pattern, content): + candidate = match.group() + try: + json.loads(candidate) + return candidate + except json.JSONDecodeError: + continue + + # Try more aggressive nested JSON extraction for complex objects + # Find the first { and match to the last } + first_brace = content.find("{") + last_brace = content.rfind("}") + if first_brace != -1 and last_brace > first_brace: + candidate = content[first_brace : last_brace + 1] + try: + json.loads(candidate) + return candidate + except json.JSONDecodeError: + pass + + # Try for arrays + first_bracket = content.find("[") + last_bracket = content.rfind("]") + if first_bracket != -1 and last_bracket > first_bracket: + candidate = content[first_bracket : last_bracket + 1] + try: + json.loads(candidate) + return candidate + except json.JSONDecodeError: + pass + + return None + + @staticmethod + def enforce_json_format(content: str, strict: bool = False) -> str: + """ + Enforce JSON format on content. + + Args: + content: The content to enforce JSON format on + strict: If True, return '[]' on failure. If False, return original content. + + Returns: + Valid JSON string, or fallback value based on strict mode + """ + extracted = MessageAdapter.extract_json(content) + + if extracted: + return extracted + + if strict: + return "[]" + + return content + @staticmethod def messages_to_prompt(messages: List[Message]) -> tuple[str, Optional[str]]: """ diff --git a/src/model_service.py b/src/model_service.py new file mode 100644 index 0000000..7254937 --- /dev/null +++ b/src/model_service.py @@ -0,0 +1,141 @@ +""" +Model service for dynamically fetching available models from Anthropic API. + +This service provides: +- Dynamic model discovery from Anthropic API on startup +- Graceful fallback to static CLAUDE_MODELS when API is unavailable +- Caching of fetched models for the session lifetime +""" + +import os +import logging +from typing import List, Optional + +import httpx + +from src.constants import CLAUDE_MODELS + +logger = logging.getLogger(__name__) + +# Anthropic API configuration +ANTHROPIC_API_BASE = "https://api.anthropic.com" +ANTHROPIC_API_VERSION = "2023-06-01" +MODEL_FETCH_TIMEOUT = 10.0 # seconds + + +class ModelService: + """Fetches models from Anthropic API with fallback to constants.""" + + def __init__(self): + self._cached_models: Optional[List[str]] = None + self._http_client: Optional[httpx.AsyncClient] = None + self._initialized: bool = False + + async def initialize(self) -> None: + """Called during app startup - fetch models from API.""" + if self._initialized: + return + + self._http_client = httpx.AsyncClient(timeout=MODEL_FETCH_TIMEOUT) + + # Attempt to fetch models from API + fetched_models = await self.fetch_models_from_api() + + if fetched_models: + self._cached_models = fetched_models + logger.info(f"Successfully fetched {len(fetched_models)} models from Anthropic API") + else: + self._cached_models = None + logger.info("Using fallback static model list from constants") + + self._initialized = True + + async def shutdown(self) -> None: + """Close HTTP client on app shutdown.""" + if self._http_client: + await self._http_client.aclose() + self._http_client = None + self._cached_models = None + self._initialized = False + + async def fetch_models_from_api(self) -> Optional[List[str]]: + """ + Fetch models from Anthropic API. + + GET https://api.anthropic.com/v1/models + Headers: + - x-api-key: {ANTHROPIC_API_KEY} + - anthropic-version: 2023-06-01 + + Returns list of model IDs on success, None on failure. + """ + api_key = os.getenv("ANTHROPIC_API_KEY") + + if not api_key: + logger.debug("ANTHROPIC_API_KEY not set, skipping API model fetch") + return None + + if not self._http_client: + self._http_client = httpx.AsyncClient(timeout=MODEL_FETCH_TIMEOUT) + + try: + response = await self._http_client.get( + f"{ANTHROPIC_API_BASE}/v1/models", + headers={ + "x-api-key": api_key, + "anthropic-version": ANTHROPIC_API_VERSION, + }, + ) + + if response.status_code == 200: + data = response.json() + # Extract model IDs from the response + # API returns {"data": [{"id": "claude-...", ...}, ...]} + models = [] + for model_data in data.get("data", []): + model_id = model_data.get("id") + if model_id: + models.append(model_id) + + if models: + logger.debug(f"Fetched models from API: {models}") + return models + else: + logger.warning("API returned empty model list") + return None + + elif response.status_code == 401: + logger.warning("Anthropic API authentication failed (401). Check ANTHROPIC_API_KEY.") + return None + elif response.status_code == 429: + logger.warning("Anthropic API rate limited (429). Using fallback models.") + return None + else: + logger.warning( + f"Anthropic API returned status {response.status_code}. Using fallback models." + ) + return None + + except httpx.TimeoutException: + logger.warning(f"Anthropic API request timed out after {MODEL_FETCH_TIMEOUT}s") + return None + except httpx.RequestError as e: + logger.warning(f"Network error fetching models from Anthropic API: {e}") + return None + except Exception as e: + logger.warning(f"Unexpected error fetching models: {e}") + return None + + def get_models(self) -> List[str]: + """Return cached models or CLAUDE_MODELS fallback.""" + if self._cached_models: + return self._cached_models + return list(CLAUDE_MODELS) + + def is_initialized(self) -> bool: + """Check if service has been initialized.""" + return self._initialized + + +# Global singleton instance +model_service = ModelService() diff --git a/src/models.py b/src/models.py index 82e85f4..b513f2e 100644 --- a/src/models.py +++ b/src/models.py @@ -53,6 +53,15 @@ class StreamOptions(BaseModel): ) +class ResponseFormat(BaseModel): + """OpenAI-compatible response format specification.""" + + type: Literal["text", "json_object"] = Field( + default="text", + description="Response format type - 'text' for regular text, 'json_object' for JSON mode", + ) + + class ChatCompletionRequest(BaseModel): model: str = Field(default_factory=get_default_model) messages: List[Message] @@ -79,6 +88,10 @@ class ChatCompletionRequest(BaseModel): stream_options: Optional[StreamOptions] = Field( default=None, description="Options for streaming responses" ) + response_format: Optional[ResponseFormat] = Field( + default=None, + description="Response format - use {'type': 'json_object'} for JSON mode", + ) @field_validator("n") @classmethod diff --git a/src/parameter_validator.py b/src/parameter_validator.py index e45452f..2bf1b70 100644 --- a/src/parameter_validator.py +++ b/src/parameter_validator.py @@ -3,17 +3,33 @@ """ import logging -from typing import Dict, Any, List, Optional +from typing import Dict, Any, List, Optional, Set from src.models import ChatCompletionRequest from src.constants import CLAUDE_MODELS logger = logging.getLogger(__name__) +def get_supported_models() -> Set[str]: + """Get supported models from model_service or fallback to constants.""" + try: + from src.model_service import model_service + + return set(model_service.get_models()) + except ImportError: + return set(CLAUDE_MODELS) + + class ParameterValidator: """Validates and maps OpenAI Chat Completions parameters to Claude Code SDK options.""" - # Use models from constants (single source of truth) + @classmethod + def get_supported_models(cls) -> Set[str]: + """Get currently supported models (dynamic or fallback).""" + return get_supported_models() + + # Legacy class attribute for backwards compatibility + # Use get_supported_models() method for dynamic models SUPPORTED_MODELS = set(CLAUDE_MODELS) # Valid permission modes for Claude Code SDK @@ -22,9 +38,10 @@ class ParameterValidator: @classmethod def validate_model(cls, model: str) -> bool: """Validate that the model is supported by Claude Code SDK.""" - if model not in cls.SUPPORTED_MODELS: + supported = cls.get_supported_models() + if model not in supported: logger.warning( - f"Model '{model}' is not in the known supported models list. It will still be attempted but may fail. Supported models: {sorted(cls.SUPPORTED_MODELS)}" + f"Model '{model}' is not in the known supported models list. It will still be attempted but may fail. Supported models: {sorted(supported)}" ) # Return True anyway to allow graceful degradation return True @@ -164,6 +181,8 @@ def generate_compatibility_report(cls, request: ChatCompletionRequest) -> Dict[s report["supported_parameters"].append("stream") if request.user: report["supported_parameters"].append("user (for logging)") + if request.response_format: + report["supported_parameters"].append("response_format") # Check unsupported parameters with suggestions if request.temperature != 1.0: diff --git a/tests/test_json_format_unit.py b/tests/test_json_format_unit.py new file mode 100644 index 0000000..102db4d --- /dev/null +++ b/tests/test_json_format_unit.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +""" +Unit tests for JSON format functionality. + +Tests the JSON extraction and enforcement methods in MessageAdapter, +as well as the ResponseFormat model. +""" + +import pytest + +from src.message_adapter import MessageAdapter +from src.models import ResponseFormat, ChatCompletionRequest, Message + + +class TestExtractJson: + """Test MessageAdapter.extract_json() method.""" + + def test_extract_json_pure(self): + """Pure JSON content is returned as-is.""" + content = '{"name": "test", "value": 123}' + result = MessageAdapter.extract_json(content) + assert result == content + + def test_extract_json_pure_array(self): + """Pure JSON array is returned as-is.""" + content = '[1, 2, 3, 4, 5]' + result = MessageAdapter.extract_json(content) + assert result == content + + def test_extract_json_pure_with_whitespace(self): + """Pure JSON with surrounding whitespace is extracted.""" + content = ' \n{"key": "value"}\n ' + result = MessageAdapter.extract_json(content) + assert result == '{"key": "value"}' + + def test_extract_json_markdown_block(self): + """Extracts JSON from ```json code block.""" + content = '''Here is the data: +```json +{"items": [1, 2, 3]} +``` +That's all!''' + result = MessageAdapter.extract_json(content) + assert result == '{"items": [1, 2, 3]}' + + def test_extract_json_generic_code_block(self): + """Extracts JSON from generic ``` code block.""" + content = '''Response: +``` +{"status": "ok"} +```''' + result = MessageAdapter.extract_json(content) + assert result == '{"status": "ok"}' + + def test_extract_json_embedded_object(self): + """Finds JSON object embedded in text.""" + content = 'The result is {"success": true, "count": 42} as expected.' + result = MessageAdapter.extract_json(content) + assert result == '{"success": true, "count": 42}' + + def test_extract_json_embedded_array(self): + """Finds JSON array embedded in text.""" + content = 'Available items: [1, 2, 3] are ready.' + result = MessageAdapter.extract_json(content) + assert result == '[1, 2, 3]' + + def test_extract_json_nested_object(self): + """Extracts nested JSON objects.""" + content = '''Result: {"outer": {"inner": {"deep": "value"}}}''' + result = MessageAdapter.extract_json(content) + assert result is not None + assert '"deep": "value"' in result + + def test_extract_json_complex_array(self): + """Extracts complex JSON arrays.""" + content = '''Data: [{"id": 1}, {"id": 2}]''' + result = MessageAdapter.extract_json(content) + assert result is not None + assert '"id": 1' in result + + def test_extract_json_no_json(self): + """Returns None when no valid JSON found.""" + content = 'This is just plain text with no JSON.' + result = MessageAdapter.extract_json(content) + assert result is None + + def test_extract_json_invalid_json(self): + """Returns None for malformed JSON.""" + content = '{"broken: json' + result = MessageAdapter.extract_json(content) + assert result is None + + def test_extract_json_empty_string(self): + """Returns None for empty string.""" + result = MessageAdapter.extract_json('') + assert result is None + + def test_extract_json_none_input(self): + """Returns None for None input.""" + result = MessageAdapter.extract_json(None) + assert result is None + + def test_extract_json_prefers_code_block(self): + """Prefers code block JSON over embedded JSON.""" + content = '''Text {"wrong": "json"} +```json +{"correct": "json"} +```''' + result = MessageAdapter.extract_json(content) + assert result == '{"correct": "json"}' + + def test_extract_json_multiline(self): + """Extracts multiline JSON from code block.""" + content = '''```json +{ + "name": "test", + "items": [ + 1, + 2, + 3 + ] +} +```''' + result = MessageAdapter.extract_json(content) + assert result is not None + assert '"name": "test"' in result + assert '"items"' in result + + +class TestEnforceJsonFormat: + """Test MessageAdapter.enforce_json_format() method.""" + + def test_enforce_json_valid_object(self): + """Valid JSON object passes through.""" + content = '{"key": "value"}' + result = MessageAdapter.enforce_json_format(content) + assert result == content + + def test_enforce_json_valid_array(self): + """Valid JSON array passes through.""" + content = '[1, 2, 3]' + result = MessageAdapter.enforce_json_format(content) + assert result == content + + def test_enforce_json_extracts_from_text(self): + """Extracts JSON from surrounding text.""" + content = 'Here is the result: {"data": 123}' + result = MessageAdapter.enforce_json_format(content) + assert result == '{"data": 123}' + + def test_enforce_json_strict_fallback(self): + """Returns '[]' on failure in strict mode.""" + content = 'No JSON here at all!' + result = MessageAdapter.enforce_json_format(content, strict=True) + assert result == '[]' + + def test_enforce_json_non_strict_returns_original(self): + """Returns original content on failure in non-strict mode.""" + content = 'No JSON here at all!' + result = MessageAdapter.enforce_json_format(content, strict=False) + assert result == content + + def test_enforce_json_from_markdown(self): + """Extracts JSON from markdown code block.""" + content = '''```json +{"extracted": true} +```''' + result = MessageAdapter.enforce_json_format(content) + assert result == '{"extracted": true}' + + def test_enforce_json_empty_strict(self): + """Empty input returns '[]' in strict mode.""" + result = MessageAdapter.enforce_json_format('', strict=True) + assert result == '[]' + + +class TestResponseFormatModel: + """Test ResponseFormat Pydantic model.""" + + def test_response_format_default_text(self): + """Default type is 'text'.""" + rf = ResponseFormat() + assert rf.type == "text" + + def test_response_format_text_explicit(self): + """Can explicitly set type to 'text'.""" + rf = ResponseFormat(type="text") + assert rf.type == "text" + + def test_response_format_json_object(self): + """Can set type to 'json_object'.""" + rf = ResponseFormat(type="json_object") + assert rf.type == "json_object" + + def test_response_format_invalid_type(self): + """Invalid type raises validation error.""" + with pytest.raises(ValueError): + ResponseFormat(type="invalid") + + def test_response_format_in_request(self): + """ResponseFormat can be used in ChatCompletionRequest.""" + request = ChatCompletionRequest( + messages=[Message(role="user", content="Return JSON")], + response_format=ResponseFormat(type="json_object"), + ) + assert request.response_format is not None + assert request.response_format.type == "json_object" + + def test_response_format_none_in_request(self): + """ResponseFormat can be None in ChatCompletionRequest.""" + request = ChatCompletionRequest( + messages=[Message(role="user", content="Hello")], + ) + assert request.response_format is None + + def test_response_format_dict_input(self): + """ResponseFormat accepts dict input (OpenAI client style).""" + request = ChatCompletionRequest( + messages=[Message(role="user", content="Return JSON")], + response_format={"type": "json_object"}, + ) + assert request.response_format.type == "json_object" + + +class TestJsonModeInstruction: + """Test JSON_MODE_INSTRUCTION constant.""" + + def test_json_mode_instruction_exists(self): + """JSON_MODE_INSTRUCTION constant exists.""" + assert hasattr(MessageAdapter, "JSON_MODE_INSTRUCTION") + + def test_json_mode_instruction_not_empty(self): + """JSON_MODE_INSTRUCTION is not empty.""" + assert len(MessageAdapter.JSON_MODE_INSTRUCTION) > 0 + + def test_json_mode_instruction_mentions_json(self): + """JSON_MODE_INSTRUCTION mentions JSON.""" + assert "JSON" in MessageAdapter.JSON_MODE_INSTRUCTION.upper() + + def test_json_mode_instruction_is_string(self): + """JSON_MODE_INSTRUCTION is a string.""" + assert isinstance(MessageAdapter.JSON_MODE_INSTRUCTION, str) + + +class TestJsonExtractionEdgeCases: + """Test edge cases for JSON extraction.""" + + def test_json_with_escaped_quotes(self): + """Handles JSON with escaped quotes.""" + content = '{"message": "He said \\"hello\\""}' + result = MessageAdapter.extract_json(content) + assert result == content + + def test_json_with_unicode(self): + """Handles JSON with unicode characters.""" + content = '{"emoji": "\\u2764", "text": "hello"}' + result = MessageAdapter.extract_json(content) + assert result is not None + + def test_json_boolean_values(self): + """Handles JSON boolean values.""" + content = '{"active": true, "deleted": false}' + result = MessageAdapter.extract_json(content) + assert result == content + + def test_json_null_value(self): + """Handles JSON null value.""" + content = '{"data": null}' + result = MessageAdapter.extract_json(content) + assert result == content + + def test_json_number_types(self): + """Handles various JSON number types.""" + content = '{"int": 42, "float": 3.14, "negative": -10, "exp": 1e5}' + result = MessageAdapter.extract_json(content) + assert result == content + + def test_deeply_nested_json(self): + """Handles deeply nested JSON.""" + content = '{"a": {"b": {"c": {"d": {"e": 1}}}}}' + result = MessageAdapter.extract_json(content) + assert result == content + + def test_json_array_of_objects(self): + """Handles array of objects.""" + content = '[{"id": 1}, {"id": 2}, {"id": 3}]' + result = MessageAdapter.extract_json(content) + assert result == content + + def test_multiple_json_blocks_returns_first_valid(self): + """When multiple code blocks exist, returns valid JSON from first.""" + content = '''```json +{"first": true} +``` +```json +{"second": true} +```''' + result = MessageAdapter.extract_json(content) + assert result == '{"first": true}' + + def test_json_with_newlines(self): + """Handles JSON with embedded newlines.""" + content = '{"text": "line1\\nline2"}' + result = MessageAdapter.extract_json(content) + assert result == content diff --git a/tests/test_model_service_unit.py b/tests/test_model_service_unit.py new file mode 100644 index 0000000..54ee3b7 --- /dev/null +++ b/tests/test_model_service_unit.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +""" +Unit tests for src/model_service.py + +Tests the ModelService class that fetches models from Anthropic API +with graceful fallback to static constants. +""" + +import pytest +from unittest.mock import patch, AsyncMock, MagicMock +import httpx + +from src.model_service import ModelService, MODEL_FETCH_TIMEOUT +from src.constants import CLAUDE_MODELS + + +class TestModelService: + """Test ModelService class.""" + + @pytest.fixture + def model_service(self): + """Create a fresh ModelService instance for each test.""" + return ModelService() + + @pytest.mark.asyncio + async def test_fetch_models_success(self, model_service): + """Successfully fetches models from API.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + {"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet"}, + {"id": "claude-haiku-4-5-20251001", "name": "Claude Haiku"}, + ] + } + + with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): + with patch.object(model_service, "_http_client") as mock_client: + mock_client.get = AsyncMock(return_value=mock_response) + + result = await model_service.fetch_models_from_api() + + assert result is not None + assert len(result) == 2 + assert "claude-sonnet-4-5-20250929" in result + assert "claude-haiku-4-5-20251001" in result + + @pytest.mark.asyncio + async def test_fetch_models_timeout(self, model_service): + """Returns None on timeout, allowing fallback to constants.""" + with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): + with patch.object(model_service, "_http_client") as mock_client: + mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout")) + + result = await model_service.fetch_models_from_api() + + assert result is None + + @pytest.mark.asyncio + async def test_fetch_models_auth_error(self, model_service): + """Returns None on 401 auth error, allowing fallback.""" + mock_response = MagicMock() + mock_response.status_code = 401 + + with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "invalid-key"}): + with patch.object(model_service, "_http_client") as mock_client: + mock_client.get = AsyncMock(return_value=mock_response) + + result = await model_service.fetch_models_from_api() + + assert result is None + + @pytest.mark.asyncio + async def test_fetch_models_rate_limited(self, model_service): + """Returns None on 429 rate limit, allowing fallback.""" + mock_response = MagicMock() + mock_response.status_code = 429 + + with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): + with patch.object(model_service, "_http_client") as mock_client: + mock_client.get = AsyncMock(return_value=mock_response) + + result = await model_service.fetch_models_from_api() + + assert result is None + + @pytest.mark.asyncio + async def test_fetch_models_network_error(self, model_service): + """Returns None on network error, allowing fallback.""" + with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): + with patch.object(model_service, "_http_client") as mock_client: + mock_client.get = AsyncMock( + side_effect=httpx.RequestError("connection failed") + ) + + result = await model_service.fetch_models_from_api() + + assert result is None + + @pytest.mark.asyncio + async def test_fetch_models_no_api_key(self, model_service): + """Returns None when no API key is set.""" + with patch.dict("os.environ", {}, clear=True): + # Ensure ANTHROPIC_API_KEY is not set + import os + if "ANTHROPIC_API_KEY" in os.environ: + del os.environ["ANTHROPIC_API_KEY"] + + result = await model_service.fetch_models_from_api() + + assert result is None + + @pytest.mark.asyncio + async def test_fetch_models_empty_response(self, model_service): + """Returns None when API returns empty model list.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": []} + + with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): + with patch.object(model_service, "_http_client") as mock_client: + mock_client.get = AsyncMock(return_value=mock_response) + + result = await model_service.fetch_models_from_api() + + assert result is None + + def test_get_models_returns_cached(self, model_service): + """Returns cached models when available.""" + model_service._cached_models = ["model-a", "model-b", "model-c"] + + result = model_service.get_models() + + assert result == ["model-a", "model-b", "model-c"] + + def test_get_models_returns_fallback(self, model_service): + """Returns CLAUDE_MODELS fallback when no cached models.""" + model_service._cached_models = None + + result = model_service.get_models() + + assert result == list(CLAUDE_MODELS) + + def test_get_models_returns_fallback_empty_cache(self, model_service): + """Returns CLAUDE_MODELS fallback when cache is empty list.""" + # Empty list is falsy, so should fall back + model_service._cached_models = [] + + result = model_service.get_models() + + # Empty list is falsy, so fallback is used + assert result == list(CLAUDE_MODELS) + + def test_is_initialized_false_by_default(self, model_service): + """Service is not initialized by default.""" + assert model_service.is_initialized() is False + + @pytest.mark.asyncio + async def test_initialize_sets_initialized(self, model_service): + """Initialize sets initialized flag.""" + with patch.object(model_service, "fetch_models_from_api", new_callable=AsyncMock) as mock: + mock.return_value = None + + await model_service.initialize() + + assert model_service.is_initialized() is True + + @pytest.mark.asyncio + async def test_initialize_caches_fetched_models(self, model_service): + """Initialize caches successfully fetched models.""" + fetched = ["claude-3-opus", "claude-3-sonnet"] + + with patch.object(model_service, "fetch_models_from_api", new_callable=AsyncMock) as mock: + mock.return_value = fetched + + await model_service.initialize() + + assert model_service._cached_models == fetched + + @pytest.mark.asyncio + async def test_initialize_only_once(self, model_service): + """Initialize only fetches models once.""" + with patch.object(model_service, "fetch_models_from_api", new_callable=AsyncMock) as mock: + mock.return_value = ["model-1"] + + await model_service.initialize() + await model_service.initialize() # Second call should be no-op + + mock.assert_called_once() + + @pytest.mark.asyncio + async def test_shutdown_closes_client(self, model_service): + """Shutdown closes the HTTP client.""" + mock_client = AsyncMock() + model_service._http_client = mock_client + model_service._initialized = True + + await model_service.shutdown() + + mock_client.aclose.assert_called_once() + assert model_service._http_client is None + assert model_service._initialized is False + + @pytest.mark.asyncio + async def test_shutdown_safe_when_not_initialized(self, model_service): + """Shutdown is safe when called before initialization.""" + # Should not raise + await model_service.shutdown() + + assert model_service._http_client is None + + +class TestModelServiceIntegration: + """Integration-style tests for ModelService.""" + + @pytest.mark.asyncio + async def test_full_lifecycle(self): + """Test full initialize-use-shutdown lifecycle.""" + service = ModelService() + + # Mock the API call + with patch.object(service, "fetch_models_from_api", new_callable=AsyncMock) as mock: + mock.return_value = ["test-model-1", "test-model-2"] + + # Initialize + await service.initialize() + assert service.is_initialized() + + # Use + models = service.get_models() + assert models == ["test-model-1", "test-model-2"] + + # Shutdown + await service.shutdown() + assert not service.is_initialized() + + # After shutdown, should return fallback + models = service.get_models() + assert models == list(CLAUDE_MODELS) + + @pytest.mark.asyncio + async def test_fallback_on_api_failure(self): + """Test that API failure results in fallback models.""" + service = ModelService() + + # Mock API failure + with patch.object(service, "fetch_models_from_api", new_callable=AsyncMock) as mock: + mock.return_value = None # API failed + + await service.initialize() + + models = service.get_models() + assert models == list(CLAUDE_MODELS) + + await service.shutdown() From 73df6481032565357f49dda29a460a8c08e968a2 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 30 Jan 2026 21:10:19 -0500 Subject: [PATCH 02/38] feat: add debug logging for JSON extraction and enforcement --- src/main.py | 7 +++++++ src/message_adapter.py | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/main.py b/src/main.py index eb1b286..8628e2a 100644 --- a/src/main.py +++ b/src/main.py @@ -801,10 +801,17 @@ async def chat_completions( # Enforce JSON format if JSON mode is enabled if json_mode: + original_len = len(assistant_content) + original_preview = assistant_content[:200] if len(assistant_content) > 200 else assistant_content + assistant_content = MessageAdapter.enforce_json_format( assistant_content, strict=True ) + logger.info(f"JSON enforcement: {original_len} chars -> {len(assistant_content)} chars") + logger.debug(f"Before enforce_json: {original_preview}...") + logger.debug(f"After enforce_json: {assistant_content[:500] if len(assistant_content) > 500 else assistant_content}") + # Add assistant response to session if using session mode if actual_session_id: assistant_message = Message(role="assistant", content=assistant_content) diff --git a/src/message_adapter.py b/src/message_adapter.py index 3f26661..979dbb8 100644 --- a/src/message_adapter.py +++ b/src/message_adapter.py @@ -2,6 +2,9 @@ from src.models import Message import re import json +import logging + +logger = logging.getLogger(__name__) class MessageAdapter: @@ -31,6 +34,7 @@ def extract_json(content: str) -> Optional[str]: Extracted JSON string, or None if no valid JSON found """ if not content: + logger.debug("extract_json: Empty content") return None content = content.strip() @@ -38,6 +42,7 @@ def extract_json(content: str) -> Optional[str]: # Case 1: Try parsing as pure JSON first try: json.loads(content) + logger.debug(f"extract_json: Already valid JSON ({len(content)} chars)") return content except json.JSONDecodeError: pass @@ -55,8 +60,10 @@ def extract_json(content: str) -> Optional[str]: match = match.strip() try: json.loads(match) + logger.debug(f"extract_json: Extracted from code block ({len(match)} chars)") return match except json.JSONDecodeError: + logger.debug("extract_json: Code block match failed validation") continue # Case 3: Find embedded JSON (objects or arrays) @@ -66,6 +73,7 @@ def extract_json(content: str) -> Optional[str]: candidate = match.group() try: json.loads(candidate) + logger.debug(f"extract_json: Extracted embedded object ({len(candidate)} chars)") return candidate except json.JSONDecodeError: continue @@ -76,6 +84,7 @@ def extract_json(content: str) -> Optional[str]: candidate = match.group() try: json.loads(candidate) + logger.debug(f"extract_json: Extracted embedded array ({len(candidate)} chars)") return candidate except json.JSONDecodeError: continue @@ -88,6 +97,7 @@ def extract_json(content: str) -> Optional[str]: candidate = content[first_brace : last_brace + 1] try: json.loads(candidate) + logger.debug(f"extract_json: Extracted via brace matching ({len(candidate)} chars)") return candidate except json.JSONDecodeError: pass @@ -99,10 +109,13 @@ def extract_json(content: str) -> Optional[str]: candidate = content[first_bracket : last_bracket + 1] try: json.loads(candidate) + logger.debug(f"extract_json: Extracted via bracket matching ({len(candidate)} chars)") return candidate except json.JSONDecodeError: pass + logger.warning(f"extract_json: No valid JSON found in {len(content)} chars") + logger.debug(f"extract_json: Content preview: {content[:500] if len(content) > 500 else content}") return None @staticmethod @@ -120,8 +133,10 @@ def enforce_json_format(content: str, strict: bool = False) -> str: extracted = MessageAdapter.extract_json(content) if extracted: + logger.debug(f"enforce_json_format: Successfully extracted ({len(extracted)} chars)") return extracted + logger.warning(f"enforce_json_format: Extraction failed, strict={strict}") if strict: return "[]" From 90b92e828a5920807b2d4de170af7d7b260acd67 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 30 Jan 2026 21:42:59 -0500 Subject: [PATCH 03/38] fix: reinforce JSON mode instruction in user prompt Claude Code SDK was ignoring JSON_MODE_INSTRUCTION in the system prompt and returning conversational text instead of JSON. Added JSON_PROMPT_SUFFIX constant that is now appended to the user prompt alongside the system prompt instruction, ensuring the model follows JSON output requirements. Changes: - Add JSON_PROMPT_SUFFIX constant to message_adapter.py - Append suffix to user prompt in both streaming and non-streaming paths - Update log messages to reflect dual-prompt approach - Bump version to 2.3.1 --- src/__init__.py | 2 +- src/main.py | 8 ++++++-- src/message_adapter.py | 8 ++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/__init__.py b/src/__init__.py index 4642a13..87c0b66 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ """Claude Code OpenAI Wrapper - A FastAPI-based OpenAI-compatible API for Claude Code.""" -__version__ = "2.3.0" +__version__ = "2.3.1" diff --git a/src/main.py b/src/main.py index 8628e2a..d79059c 100644 --- a/src/main.py +++ b/src/main.py @@ -425,7 +425,9 @@ async def generate_streaming_response( system_prompt = f"{MessageAdapter.JSON_MODE_INSTRUCTION}\n\n{system_prompt}" else: system_prompt = MessageAdapter.JSON_MODE_INSTRUCTION - logger.info("JSON mode enabled (streaming) - response will be accumulated and formatted") + # Also append to user prompt to reinforce JSON requirement + prompt = prompt + MessageAdapter.JSON_PROMPT_SUFFIX + logger.info("JSON mode enabled (streaming) - instruction added to system and user prompt") # Filter content for unsupported features prompt = MessageAdapter.filter_content(prompt) @@ -745,7 +747,9 @@ async def chat_completions( system_prompt = f"{MessageAdapter.JSON_MODE_INSTRUCTION}\n\n{system_prompt}" else: system_prompt = MessageAdapter.JSON_MODE_INSTRUCTION - logger.info("JSON mode enabled - response will be enforced as valid JSON") + # Also append to user prompt to reinforce JSON requirement + prompt = prompt + MessageAdapter.JSON_PROMPT_SUFFIX + logger.info("JSON mode enabled - instruction added to system and user prompt") # Filter content prompt = MessageAdapter.filter_content(prompt) diff --git a/src/message_adapter.py b/src/message_adapter.py index 979dbb8..990b3e7 100644 --- a/src/message_adapter.py +++ b/src/message_adapter.py @@ -17,6 +17,14 @@ class MessageAdapter: "Start with [ or { and end with ] or }." ) + # Suffix to append to user prompt to reinforce JSON mode + JSON_PROMPT_SUFFIX = ( + "\n\n---\n" + "OUTPUT INSTRUCTION: Your entire response must be valid JSON. " + "Start with [ or { and end with ] or }. " + "Do not include any other text, explanation, or markdown." + ) + @staticmethod def extract_json(content: str) -> Optional[str]: """ From cabf0f6c478871a76c5fecd8d969e79f5ea56de2 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Sat, 31 Jan 2026 18:54:27 -0500 Subject: [PATCH 04/38] feat: strengthen JSON mode instructions and add debug logging - Updated JSON_MODE_INSTRUCTION with explicit first/last character rules - Added explicit prohibition of markdown code blocks in instructions - Updated JSON_PROMPT_SUFFIX with more concise output format - Added log_json_structure() helper for debugging JSON responses - Added boundary and structure logging in streaming/non-streaming paths --- src/main.py | 36 +++++++++++++++++++++++++++++++++--- src/message_adapter.py | 14 ++++++++------ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/main.py b/src/main.py index d79059c..d97fba7 100644 --- a/src/main.py +++ b/src/main.py @@ -70,6 +70,20 @@ runtime_api_key = None +def log_json_structure(content: str, log: logging.Logger) -> None: + """Log the structure of a JSON response for debugging.""" + try: + data = json.loads(content) + if isinstance(data, list): + log.debug(f"JSON array with {len(data)} items") + if len(data) > 0 and isinstance(data[0], dict): + log.debug(f"First item fields: {list(data[0].keys())}") + elif isinstance(data, dict): + log.debug(f"JSON object fields: {list(data.keys())}") + except json.JSONDecodeError: + log.debug("Response is not valid JSON") + + def generate_secure_token(length: int = 32) -> str: """Generate a secure random token for API authentication.""" alphabet = string.ascii_letters + string.digits + "-_" @@ -582,8 +596,18 @@ async def generate_streaming_response( # Combine buffered content and enforce JSON format combined_content = "".join(json_mode_buffer) + + if DEBUG_MODE or VERBOSE: + raw_preview = combined_content[:50] if len(combined_content) > 50 else combined_content + raw_end = combined_content[-30:] if len(combined_content) > 30 else combined_content + logger.debug(f"Raw response: starts='{raw_preview}' ends='...{raw_end}'") + json_content = MessageAdapter.enforce_json_format(combined_content, strict=True) + if DEBUG_MODE or VERBOSE: + logger.debug(f"Extracted JSON preview: {json_content[:200]}") + log_json_structure(json_content, logger) + # Emit as single chunk json_chunk = ChatCompletionStreamResponse( id=request_id, @@ -806,15 +830,21 @@ async def chat_completions( # Enforce JSON format if JSON mode is enabled if json_mode: original_len = len(assistant_content) - original_preview = assistant_content[:200] if len(assistant_content) > 200 else assistant_content + + if DEBUG_MODE or VERBOSE: + raw_preview = assistant_content[:50] if len(assistant_content) > 50 else assistant_content + raw_end = assistant_content[-30:] if len(assistant_content) > 30 else assistant_content + logger.debug(f"Raw response: starts='{raw_preview}' ends='...{raw_end}'") assistant_content = MessageAdapter.enforce_json_format( assistant_content, strict=True ) logger.info(f"JSON enforcement: {original_len} chars -> {len(assistant_content)} chars") - logger.debug(f"Before enforce_json: {original_preview}...") - logger.debug(f"After enforce_json: {assistant_content[:500] if len(assistant_content) > 500 else assistant_content}") + + if DEBUG_MODE or VERBOSE: + logger.debug(f"Extracted JSON preview: {assistant_content[:200]}") + log_json_structure(assistant_content, logger) # Add assistant response to session if using session mode if actual_session_id: diff --git a/src/message_adapter.py b/src/message_adapter.py index 990b3e7..4da14f2 100644 --- a/src/message_adapter.py +++ b/src/message_adapter.py @@ -12,17 +12,19 @@ class MessageAdapter: # Instruction to prepend to system prompt for JSON mode JSON_MODE_INSTRUCTION = ( - "CRITICAL: Respond with ONLY valid JSON. " - "No explanations, no markdown, no code blocks. " - "Start with [ or { and end with ] or }." + "CRITICAL: Your response must be ONLY valid JSON. " + "The very first character of your response must be [ or {. " + "The very last character of your response must be ] or }. " + "Do NOT wrap in markdown code blocks. " + "Do NOT use ``` anywhere in your response." ) # Suffix to append to user prompt to reinforce JSON mode JSON_PROMPT_SUFFIX = ( "\n\n---\n" - "OUTPUT INSTRUCTION: Your entire response must be valid JSON. " - "Start with [ or { and end with ] or }. " - "Do not include any other text, explanation, or markdown." + "OUTPUT FORMAT: Raw JSON only. " + "First character: [ or {. Last character: ] or }. " + "No markdown, no code fences, no explanation." ) @staticmethod From 5e27ccb2ac2df022f585e144be346f24c9abf110 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Wed, 4 Feb 2026 18:22:12 -0800 Subject: [PATCH 05/38] feat: improve JSON extraction reliability and add request cache - Improve JSON mode instructions with numbered rules and explicit prohibition of preambles - Add COMMON_PREAMBLES constant with 19 common Claude preambles - Implement balanced brace/bracket matching algorithm that handles escaped quotes and braces inside strings correctly - Add JsonExtractionResult dataclass and extract_json_with_metadata() for detailed extraction tracking - Add enforce_json_format_with_metadata() for metadata-enabled JSON enforcement - Add _log_extraction_diagnostics() for debugging extraction failures - Create optional request deduplication cache with LRU eviction and TTL - Add cache management endpoints: GET /v1/cache/stats, POST /v1/cache/clear - Update version to 2.4.0 - Add comprehensive unit tests for all new functionality The JSON extraction priority order is now: 1. Pure JSON (fast path) 2. Preamble removal + parse 3. Markdown code block extraction 4. Balanced brace/bracket matching 5. First-to-last fallback --- CHANGELOG.md | 44 ++++ src/__init__.py | 2 +- src/main.py | 65 ++++- src/message_adapter.py | 411 ++++++++++++++++++++++++++++--- src/request_cache.py | 248 +++++++++++++++++++ tests/test_json_format_unit.py | 186 +++++++++++++- tests/test_request_cache_unit.py | 239 ++++++++++++++++++ 7 files changed, 1151 insertions(+), 44 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/request_cache.py create mode 100644 tests/test_request_cache_unit.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4dcc424 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +All notable changes to the Claude Code OpenAI Wrapper project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.4.0] - 2026-02-04 + +### Added + +- **Improved JSON Mode Instructions**: Enhanced system prompt instructions with numbered rules format, explicit prohibition of preambles, and stronger emphasis on first/last character requirements +- **Common Preamble Detection**: New `COMMON_PREAMBLES` constant with 19 common Claude preambles that are automatically stripped +- **Balanced JSON Extraction**: New `_find_balanced_json()` helper method using brace/bracket matching that correctly handles escaped quotes and braces inside strings +- **JSON Extraction Metadata**: New `JsonExtractionResult` dataclass and `extract_json_with_metadata()` method providing detailed extraction information +- **Metadata-Enabled Enforcement**: New `enforce_json_format_with_metadata()` method returning both extracted content and extraction details +- **Enhanced Extraction Diagnostics**: New `_log_extraction_diagnostics()` method for detailed debugging of extraction failures +- **Request Deduplication Cache**: Optional caching layer for identical requests with LRU eviction and TTL expiration + - Configure via environment variables: `REQUEST_CACHE_ENABLED`, `REQUEST_CACHE_MAX_SIZE`, `REQUEST_CACHE_TTL_SECONDS` + - Enable per-request via `X-Enable-Cache: true` header +- **Cache Management Endpoints**: + - `GET /v1/cache/stats` - View cache statistics + - `POST /v1/cache/clear` - Clear all cached entries +- **Unit Tests**: Comprehensive tests for balanced JSON extraction, metadata tracking, and request cache + +### Changed + +- **JSON Extraction Priority**: Reordered extraction methods for better reliability: + 1. Pure JSON (fast path) + 2. Preamble removal + parse + 3. Markdown code block extraction + 4. Balanced brace/bracket matching + 5. First-to-last fallback +- **Improved Logging**: JSON enforcement now logs extraction method used (e.g., `method=preamble_removed`) +- **Debug Output**: Enhanced debug logging with extraction metadata in both streaming and non-streaming modes + +### Fixed + +- JSON extraction now correctly handles escaped quotes (`\"`) within strings +- JSON extraction no longer confused by braces/brackets inside string values + +## [2.3.1] - Previous Release + +Initial tracked version with JSON mode support. diff --git a/src/__init__.py b/src/__init__.py index 87c0b66..ec92ae7 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ """Claude Code OpenAI Wrapper - A FastAPI-based OpenAI-compatible API for Claude Code.""" -__version__ = "2.3.1" +__version__ = "2.4.0" diff --git a/src/main.py b/src/main.py index d97fba7..458db53 100644 --- a/src/main.py +++ b/src/main.py @@ -53,6 +53,7 @@ ) from src.constants import CLAUDE_MODELS, CLAUDE_TOOLS, DEFAULT_ALLOWED_TOOLS from src.model_service import model_service +from src.request_cache import request_cache # Load environment variables load_dotenv() @@ -602,9 +603,12 @@ async def generate_streaming_response( raw_end = combined_content[-30:] if len(combined_content) > 30 else combined_content logger.debug(f"Raw response: starts='{raw_preview}' ends='...{raw_end}'") - json_content = MessageAdapter.enforce_json_format(combined_content, strict=True) + json_content, extraction_metadata = MessageAdapter.enforce_json_format_with_metadata( + combined_content, strict=True + ) if DEBUG_MODE or VERBOSE: + logger.debug(f"JSON extraction metadata: {extraction_metadata}") logger.debug(f"Extracted JSON preview: {json_content[:200]}") log_json_structure(json_content, logger) @@ -739,6 +743,17 @@ async def chat_completions( ) else: # Non-streaming response + # Check cache if enabled and requested via header + cache_enabled = request.headers.get("X-Enable-Cache", "").lower() in ("true", "1", "yes") + if cache_enabled and request_cache.enabled: + request_dict = request_body.model_dump() + cached_response = request_cache.get(request_dict) + if cached_response: + logger.info(f"Cache hit for request {request_id}") + # Return cached response with updated request ID + cached_response["id"] = request_id + return cached_response + # Process messages with session management all_messages, actual_session_id = session_manager.process_messages( request_body.messages, request_body.session_id @@ -836,13 +851,15 @@ async def chat_completions( raw_end = assistant_content[-30:] if len(assistant_content) > 30 else assistant_content logger.debug(f"Raw response: starts='{raw_preview}' ends='...{raw_end}'") - assistant_content = MessageAdapter.enforce_json_format( + assistant_content, extraction_metadata = MessageAdapter.enforce_json_format_with_metadata( assistant_content, strict=True ) - logger.info(f"JSON enforcement: {original_len} chars -> {len(assistant_content)} chars") + logger.info(f"JSON enforcement: {original_len} chars -> {len(assistant_content)} chars " + f"(method={extraction_metadata.get('method', 'unknown')})") if DEBUG_MODE or VERBOSE: + logger.debug(f"JSON extraction metadata: {extraction_metadata}") logger.debug(f"Extracted JSON preview: {assistant_content[:200]}") log_json_structure(assistant_content, logger) @@ -873,6 +890,13 @@ async def chat_completions( ), ) + # Store in cache if enabled + if cache_enabled and request_cache.enabled: + request_dict = request_body.model_dump() + response_dict = response.model_dump() + request_cache.set(request_dict, response_dict) + logger.debug(f"Cached response for request {request_id}") + return response except HTTPException: @@ -2029,6 +2053,41 @@ async def get_mcp_stats( return mcp_client.get_stats() +# ============================================================================ +# Cache Endpoints +# ============================================================================ + + +@app.get("/v1/cache/stats") +@rate_limit_endpoint("general") +async def get_cache_stats( + request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +): + """Get request cache statistics. + + Returns information about cache configuration, current size, hit/miss rates, + and eviction counts. Cache is opt-in and disabled by default. + + Enable cache by setting REQUEST_CACHE_ENABLED=true environment variable. + """ + await verify_api_key(request, credentials) + return request_cache.get_stats() + + +@app.post("/v1/cache/clear") +@rate_limit_endpoint("general") +async def clear_cache( + request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +): + """Clear all cached responses. + + Returns the number of entries that were cleared. + """ + await verify_api_key(request, credentials) + count = request_cache.clear() + return {"message": f"Cleared {count} cache entries", "entries_cleared": count} + + @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): """Format HTTP exceptions as OpenAI-style errors.""" diff --git a/src/message_adapter.py b/src/message_adapter.py index 4da14f2..1603ea0 100644 --- a/src/message_adapter.py +++ b/src/message_adapter.py @@ -1,4 +1,5 @@ -from typing import List, Optional, Dict, Any +from typing import List, Optional, Dict, Any, Tuple +from dataclasses import dataclass from src.models import Message import re import json @@ -7,35 +8,184 @@ logger = logging.getLogger(__name__) +@dataclass +class JsonExtractionResult: + """Result of JSON extraction with metadata about the extraction process.""" + content: Optional[str] + success: bool + method: str # "direct", "preamble_removed", "code_block", "brace_match", "fallback", "failed" + original_length: int + extracted_length: int + preamble_found: Optional[str] = None + + class MessageAdapter: """Converts between OpenAI message format and Claude Code prompts.""" # Instruction to prepend to system prompt for JSON mode JSON_MODE_INSTRUCTION = ( - "CRITICAL: Your response must be ONLY valid JSON. " - "The very first character of your response must be [ or {. " - "The very last character of your response must be ] or }. " - "Do NOT wrap in markdown code blocks. " - "Do NOT use ``` anywhere in your response." + "CRITICAL JSON OUTPUT RULES - FOLLOW EXACTLY:\n" + "1. Your ENTIRE response must be valid JSON - nothing else\n" + "2. The FIRST character must be { or [ (no exceptions)\n" + "3. The LAST character must be } or ] (no exceptions)\n" + "4. FORBIDDEN: Do NOT write 'Here is the JSON:', 'Here's the response:', or ANY preamble\n" + "5. FORBIDDEN: Do NOT use markdown code blocks (```)\n" + "6. FORBIDDEN: Do NOT add any explanation before or after the JSON\n" + "7. Start typing the JSON immediately - your first keystroke must be { or [" ) # Suffix to append to user prompt to reinforce JSON mode JSON_PROMPT_SUFFIX = ( "\n\n---\n" - "OUTPUT FORMAT: Raw JSON only. " - "First character: [ or {. Last character: ] or }. " - "No markdown, no code fences, no explanation." + "RESPOND WITH RAW JSON ONLY:\n" + "- First character: { or [\n" + "- Last character: } or ]\n" + "- No preamble like 'Here is...' or 'Here's...'\n" + "- No markdown, no code fences, no explanation" ) + # Common preambles that Claude may add before JSON output + COMMON_PREAMBLES = [ + "Here's the JSON:", + "Here is the JSON:", + "Here's the response:", + "Here is the response:", + "Here's your JSON:", + "Here is your JSON:", + "Here's the JSON response:", + "Here is the JSON response:", + "Here's the data:", + "Here is the data:", + "Here's the result:", + "Here is the result:", + "Here's the output:", + "Here is the output:", + "The JSON is:", + "JSON response:", + "Response:", + "Output:", + "Result:", + ] + + @staticmethod + def _find_balanced_json(content: str, start_char: str, end_char: str) -> Optional[str]: + """ + Find balanced JSON structure using brace/bracket matching. + + Handles escaped quotes and braces inside strings correctly. + + Args: + content: The content to search in + start_char: Opening character ('{' or '[') + end_char: Closing character ('}' or ']') + + Returns: + Matched JSON substring or None if not found + """ + start_idx = content.find(start_char) + if start_idx == -1: + return None + + depth = 0 + in_string = False + escape_next = False + + for i, char in enumerate(content[start_idx:], start=start_idx): + if escape_next: + escape_next = False + continue + + if char == '\\': + escape_next = True + continue + + if char == '"' and not escape_next: + in_string = not in_string + continue + + if in_string: + continue + + if char == start_char: + depth += 1 + elif char == end_char: + depth -= 1 + if depth == 0: + candidate = content[start_idx:i + 1] + try: + json.loads(candidate) + return candidate + except json.JSONDecodeError: + # Keep looking for next valid match + return None + + return None + + @staticmethod + def _log_extraction_diagnostics(content: str) -> None: + """Log diagnostics to help debug JSON extraction failures.""" + logger.debug("=== JSON Extraction Diagnostics ===") + + # Check for code fences + if "```" in content: + fence_count = content.count("```") + logger.debug(f"Found {fence_count} code fence markers (```) in content") + if fence_count % 2 != 0: + logger.debug("Odd number of fences - malformed code block?") + + # Check for common preambles + content_lower = content.lower().strip() + for preamble in MessageAdapter.COMMON_PREAMBLES: + if content_lower.startswith(preamble.lower()): + logger.debug(f"Content starts with preamble: '{preamble}'") + break + + # Check brace/bracket balance + open_braces = content.count("{") + close_braces = content.count("}") + open_brackets = content.count("[") + close_brackets = content.count("]") + + logger.debug(f"Brace balance: {{ = {open_braces}, }} = {close_braces}") + logger.debug(f"Bracket balance: [ = {open_brackets}, ] = {close_brackets}") + + if open_braces != close_braces: + logger.debug("Unbalanced braces - may indicate truncated or malformed JSON") + if open_brackets != close_brackets: + logger.debug("Unbalanced brackets - may indicate truncated or malformed JSON") + + # First and last character analysis + if content: + first_char = content[0] if content else "" + last_char = content[-1] if content else "" + logger.debug(f"First character: '{first_char}', Last character: '{last_char}'") + + if first_char not in "{[": + logger.debug("First char is not { or [ - content has preamble or is not JSON") + if last_char not in "}]": + logger.debug("Last char is not } or ] - content has suffix or is not JSON") + + # Content preview + preview_len = 200 + if len(content) > preview_len: + logger.debug(f"Content preview (first {preview_len}): {content[:preview_len]}...") + logger.debug(f"Content preview (last 100): ...{content[-100:]}") + else: + logger.debug(f"Full content: {content}") + + logger.debug("=== End Diagnostics ===") + @staticmethod def extract_json(content: str) -> Optional[str]: """ Extract JSON from content. - Handles: - 1. Pure JSON (content is already valid JSON) - 2. Markdown code blocks (```json ... ```) - 3. Embedded JSON (JSON within other text) + Priority order: + 1. Pure JSON (content is already valid JSON) - fast path + 2. Preamble removal + parse (strip common Claude preambles) + 3. Markdown code blocks (```json ... ```) + 4. Balanced brace/bracket matching (handles nested structures) + 5. First-to-last fallback (find first { to last }) Args: content: The content to extract JSON from @@ -47,9 +197,10 @@ def extract_json(content: str) -> Optional[str]: logger.debug("extract_json: Empty content") return None + original_content = content content = content.strip() - # Case 1: Try parsing as pure JSON first + # Case 1: Try parsing as pure JSON first (fast path) try: json.loads(content) logger.debug(f"extract_json: Already valid JSON ({len(content)} chars)") @@ -57,8 +208,20 @@ def extract_json(content: str) -> Optional[str]: except json.JSONDecodeError: pass - # Case 2: Extract from markdown code blocks - # Match ```json ... ``` or ``` ... ``` + # Case 2: Try removing common preambles + content_lower = content.lower() + for preamble in MessageAdapter.COMMON_PREAMBLES: + if content_lower.startswith(preamble.lower()): + stripped = content[len(preamble):].strip() + try: + json.loads(stripped) + logger.debug(f"extract_json: Extracted after removing preamble '{preamble}' ({len(stripped)} chars)") + return stripped + except json.JSONDecodeError: + # Preamble removed but still not valid - try other methods + break + + # Case 3: Extract from markdown code blocks code_block_patterns = [ r"```json\s*([\s\S]*?)\s*```", # ```json block r"```\s*([\s\S]*?)\s*```", # generic ``` block @@ -76,57 +239,188 @@ def extract_json(content: str) -> Optional[str]: logger.debug("extract_json: Code block match failed validation") continue - # Case 3: Find embedded JSON (objects or arrays) - # Look for JSON objects {...} - object_pattern = r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}" - for match in re.finditer(object_pattern, content): - candidate = match.group() + # Case 4: Balanced brace/bracket matching (new algorithm) + # Try object first + balanced_obj = MessageAdapter._find_balanced_json(content, "{", "}") + if balanced_obj: + logger.debug(f"extract_json: Extracted via balanced brace matching ({len(balanced_obj)} chars)") + return balanced_obj + + # Try array + balanced_arr = MessageAdapter._find_balanced_json(content, "[", "]") + if balanced_arr: + logger.debug(f"extract_json: Extracted via balanced bracket matching ({len(balanced_arr)} chars)") + return balanced_arr + + # Case 5: First-to-last fallback (less precise but handles some edge cases) + first_brace = content.find("{") + last_brace = content.rfind("}") + if first_brace != -1 and last_brace > first_brace: + candidate = content[first_brace : last_brace + 1] try: json.loads(candidate) - logger.debug(f"extract_json: Extracted embedded object ({len(candidate)} chars)") + logger.debug(f"extract_json: Extracted via first-to-last brace ({len(candidate)} chars)") return candidate except json.JSONDecodeError: - continue + pass - # Look for JSON arrays [...] - array_pattern = r"\[[^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*\]" - for match in re.finditer(array_pattern, content): - candidate = match.group() + first_bracket = content.find("[") + last_bracket = content.rfind("]") + if first_bracket != -1 and last_bracket > first_bracket: + candidate = content[first_bracket : last_bracket + 1] try: json.loads(candidate) - logger.debug(f"extract_json: Extracted embedded array ({len(candidate)} chars)") + logger.debug(f"extract_json: Extracted via first-to-last bracket ({len(candidate)} chars)") return candidate except json.JSONDecodeError: - continue + pass + + # Extraction failed - log diagnostics + logger.warning(f"extract_json: No valid JSON found in {len(content)} chars") + MessageAdapter._log_extraction_diagnostics(original_content) + return None - # Try more aggressive nested JSON extraction for complex objects - # Find the first { and match to the last } + @staticmethod + def extract_json_with_metadata(content: str) -> JsonExtractionResult: + """ + Extract JSON from content and return metadata about the extraction process. + + This method provides detailed information about how the extraction was performed, + useful for debugging and monitoring. + + Args: + content: The content to extract JSON from + + Returns: + JsonExtractionResult with extraction details + """ + if not content: + return JsonExtractionResult( + content=None, + success=False, + method="failed", + original_length=0, + extracted_length=0, + ) + + original_length = len(content) + content = content.strip() + + # Case 1: Try parsing as pure JSON first (fast path) + try: + json.loads(content) + return JsonExtractionResult( + content=content, + success=True, + method="direct", + original_length=original_length, + extracted_length=len(content), + ) + except json.JSONDecodeError: + pass + + # Case 2: Try removing common preambles + content_lower = content.lower() + for preamble in MessageAdapter.COMMON_PREAMBLES: + if content_lower.startswith(preamble.lower()): + stripped = content[len(preamble):].strip() + try: + json.loads(stripped) + return JsonExtractionResult( + content=stripped, + success=True, + method="preamble_removed", + original_length=original_length, + extracted_length=len(stripped), + preamble_found=preamble, + ) + except json.JSONDecodeError: + break + + # Case 3: Extract from markdown code blocks + code_block_patterns = [ + r"```json\s*([\s\S]*?)\s*```", + r"```\s*([\s\S]*?)\s*```", + ] + + for pattern in code_block_patterns: + matches = re.findall(pattern, content, re.IGNORECASE) + for match in matches: + match = match.strip() + try: + json.loads(match) + return JsonExtractionResult( + content=match, + success=True, + method="code_block", + original_length=original_length, + extracted_length=len(match), + ) + except json.JSONDecodeError: + continue + + # Case 4: Balanced brace/bracket matching + balanced_obj = MessageAdapter._find_balanced_json(content, "{", "}") + if balanced_obj: + return JsonExtractionResult( + content=balanced_obj, + success=True, + method="brace_match", + original_length=original_length, + extracted_length=len(balanced_obj), + ) + + balanced_arr = MessageAdapter._find_balanced_json(content, "[", "]") + if balanced_arr: + return JsonExtractionResult( + content=balanced_arr, + success=True, + method="brace_match", + original_length=original_length, + extracted_length=len(balanced_arr), + ) + + # Case 5: First-to-last fallback first_brace = content.find("{") last_brace = content.rfind("}") if first_brace != -1 and last_brace > first_brace: candidate = content[first_brace : last_brace + 1] try: json.loads(candidate) - logger.debug(f"extract_json: Extracted via brace matching ({len(candidate)} chars)") - return candidate + return JsonExtractionResult( + content=candidate, + success=True, + method="fallback", + original_length=original_length, + extracted_length=len(candidate), + ) except json.JSONDecodeError: pass - # Try for arrays first_bracket = content.find("[") last_bracket = content.rfind("]") if first_bracket != -1 and last_bracket > first_bracket: candidate = content[first_bracket : last_bracket + 1] try: json.loads(candidate) - logger.debug(f"extract_json: Extracted via bracket matching ({len(candidate)} chars)") - return candidate + return JsonExtractionResult( + content=candidate, + success=True, + method="fallback", + original_length=original_length, + extracted_length=len(candidate), + ) except json.JSONDecodeError: pass - logger.warning(f"extract_json: No valid JSON found in {len(content)} chars") - logger.debug(f"extract_json: Content preview: {content[:500] if len(content) > 500 else content}") - return None + # Failed + return JsonExtractionResult( + content=None, + success=False, + method="failed", + original_length=original_length, + extracted_length=0, + ) @staticmethod def enforce_json_format(content: str, strict: bool = False) -> str: @@ -152,6 +446,45 @@ def enforce_json_format(content: str, strict: bool = False) -> str: return content + @staticmethod + def enforce_json_format_with_metadata(content: str, strict: bool = False) -> Tuple[str, Dict[str, Any]]: + """ + Enforce JSON format on content and return metadata about the extraction. + + Args: + content: The content to enforce JSON format on + strict: If True, return '[]' on failure. If False, return original content. + + Returns: + Tuple of (extracted_content, metadata_dict) + """ + result = MessageAdapter.extract_json_with_metadata(content) + + metadata = { + "success": result.success, + "method": result.method, + "original_length": result.original_length, + "extracted_length": result.extracted_length, + "preamble_found": result.preamble_found, + "strict_mode": strict, + } + + if result.success and result.content: + logger.debug(f"enforce_json_format_with_metadata: method={result.method}, " + f"original={result.original_length}, extracted={result.extracted_length}") + if result.preamble_found: + logger.debug(f"enforce_json_format_with_metadata: removed preamble '{result.preamble_found}'") + return result.content, metadata + + logger.warning(f"enforce_json_format_with_metadata: Extraction failed, strict={strict}") + metadata["fallback_used"] = True + + if strict: + metadata["fallback_value"] = "[]" + return "[]", metadata + + return content, metadata + @staticmethod def messages_to_prompt(messages: List[Message]) -> tuple[str, Optional[str]]: """ diff --git a/src/request_cache.py b/src/request_cache.py new file mode 100644 index 0000000..bc3fe84 --- /dev/null +++ b/src/request_cache.py @@ -0,0 +1,248 @@ +""" +Request deduplication cache for Claude Code OpenAI Wrapper. + +Provides an optional caching layer for identical requests to reduce API calls +and improve response times for repeated queries. +""" + +import hashlib +import json +import os +import threading +import time +import logging +from dataclasses import dataclass, field +from typing import Dict, Any, Optional +from collections import OrderedDict + +logger = logging.getLogger(__name__) + + +@dataclass +class CacheEntry: + """A cached response with metadata.""" + response: Dict[str, Any] + created_at: float + expires_at: float + hit_count: int = 0 + + +class RequestCache: + """ + Thread-safe LRU cache with TTL for request deduplication. + + Features: + - LRU eviction when max_size is reached + - TTL-based expiration + - Thread-safe operations + - Deterministic request hashing + """ + + def __init__( + self, + enabled: bool = True, + max_size: int = 100, + ttl_seconds: int = 60, + ): + """ + Initialize the request cache. + + Args: + enabled: Whether caching is enabled + max_size: Maximum number of entries to store + ttl_seconds: Time-to-live for cache entries in seconds + """ + self._enabled = enabled + self._max_size = max_size + self._ttl_seconds = ttl_seconds + self._cache: OrderedDict[str, CacheEntry] = OrderedDict() + self._lock = threading.RLock() + self._stats = { + "hits": 0, + "misses": 0, + "evictions": 0, + "expirations": 0, + } + + @property + def enabled(self) -> bool: + """Check if caching is enabled.""" + return self._enabled + + def _compute_hash(self, request_data: Dict[str, Any]) -> str: + """ + Compute a deterministic hash for a request. + + Only includes fields that affect the response: + - model + - messages + - temperature + - max_tokens + - response_format + + Excludes: + - stream (caching only applies to non-streaming) + - session_id + - other metadata + + Args: + request_data: The request dictionary + + Returns: + A hex string hash of the request + """ + # Extract only the fields that affect the response + hashable_fields = { + "model": request_data.get("model"), + "messages": request_data.get("messages"), + "temperature": request_data.get("temperature"), + "max_tokens": request_data.get("max_tokens"), + "response_format": request_data.get("response_format"), + "top_p": request_data.get("top_p"), + } + + # Convert to a stable JSON string (sorted keys) + json_str = json.dumps(hashable_fields, sort_keys=True, default=str) + + # Compute SHA-256 hash + return hashlib.sha256(json_str.encode()).hexdigest() + + def get(self, request_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Get a cached response for a request. + + Args: + request_data: The request dictionary + + Returns: + Cached response if found and not expired, None otherwise + """ + if not self._enabled: + return None + + cache_key = self._compute_hash(request_data) + current_time = time.time() + + with self._lock: + if cache_key not in self._cache: + self._stats["misses"] += 1 + return None + + entry = self._cache[cache_key] + + # Check if expired + if current_time > entry.expires_at: + del self._cache[cache_key] + self._stats["expirations"] += 1 + self._stats["misses"] += 1 + logger.debug(f"Cache entry expired for key {cache_key[:16]}...") + return None + + # Move to end (most recently used) + self._cache.move_to_end(cache_key) + entry.hit_count += 1 + self._stats["hits"] += 1 + + logger.debug(f"Cache hit for key {cache_key[:16]}... (hit_count={entry.hit_count})") + return entry.response + + def set(self, request_data: Dict[str, Any], response: Dict[str, Any]) -> None: + """ + Cache a response for a request. + + Args: + request_data: The request dictionary + response: The response to cache + """ + if not self._enabled: + return + + cache_key = self._compute_hash(request_data) + current_time = time.time() + + with self._lock: + # Evict if at capacity + while len(self._cache) >= self._max_size: + oldest_key = next(iter(self._cache)) + del self._cache[oldest_key] + self._stats["evictions"] += 1 + logger.debug(f"Evicted oldest cache entry {oldest_key[:16]}...") + + # Add new entry + self._cache[cache_key] = CacheEntry( + response=response, + created_at=current_time, + expires_at=current_time + self._ttl_seconds, + ) + + logger.debug(f"Cached response for key {cache_key[:16]}... (ttl={self._ttl_seconds}s)") + + def clear(self) -> int: + """ + Clear all cache entries. + + Returns: + Number of entries cleared + """ + with self._lock: + count = len(self._cache) + self._cache.clear() + logger.info(f"Cleared {count} cache entries") + return count + + def get_stats(self) -> Dict[str, Any]: + """ + Get cache statistics. + + Returns: + Dictionary with cache stats + """ + with self._lock: + total_requests = self._stats["hits"] + self._stats["misses"] + hit_rate = (self._stats["hits"] / total_requests * 100) if total_requests > 0 else 0 + + return { + "enabled": self._enabled, + "max_size": self._max_size, + "ttl_seconds": self._ttl_seconds, + "current_size": len(self._cache), + "hits": self._stats["hits"], + "misses": self._stats["misses"], + "hit_rate_percent": round(hit_rate, 2), + "evictions": self._stats["evictions"], + "expirations": self._stats["expirations"], + } + + def cleanup_expired(self) -> int: + """ + Remove all expired entries. + + Returns: + Number of entries removed + """ + current_time = time.time() + removed = 0 + + with self._lock: + expired_keys = [ + key for key, entry in self._cache.items() + if current_time > entry.expires_at + ] + + for key in expired_keys: + del self._cache[key] + removed += 1 + self._stats["expirations"] += 1 + + if removed > 0: + logger.debug(f"Cleaned up {removed} expired cache entries") + + return removed + + +# Global cache instance with configuration from environment +request_cache = RequestCache( + enabled=os.getenv("REQUEST_CACHE_ENABLED", "false").lower() in ("true", "1", "yes", "on"), + max_size=int(os.getenv("REQUEST_CACHE_MAX_SIZE", "100")), + ttl_seconds=int(os.getenv("REQUEST_CACHE_TTL_SECONDS", "60")), +) diff --git a/tests/test_json_format_unit.py b/tests/test_json_format_unit.py index 102db4d..7473b26 100644 --- a/tests/test_json_format_unit.py +++ b/tests/test_json_format_unit.py @@ -8,7 +8,7 @@ import pytest -from src.message_adapter import MessageAdapter +from src.message_adapter import MessageAdapter, JsonExtractionResult from src.models import ResponseFormat, ChatCompletionRequest, Message @@ -303,3 +303,187 @@ def test_json_with_newlines(self): content = '{"text": "line1\\nline2"}' result = MessageAdapter.extract_json(content) assert result == content + + +class TestBalancedJsonExtraction: + """Test the balanced brace/bracket matching algorithm.""" + + def test_deeply_nested_objects(self): + """Handles deeply nested objects with balanced matching.""" + content = 'Preamble: {"a": {"b": {"c": {"d": {"e": {"f": 1}}}}}}' + result = MessageAdapter.extract_json(content) + assert result == '{"a": {"b": {"c": {"d": {"e": {"f": 1}}}}}}' + + def test_mixed_nesting(self): + """Handles mixed objects and arrays.""" + content = 'Result: {"items": [{"id": 1, "nested": {"value": [1,2,3]}}]}' + result = MessageAdapter.extract_json(content) + assert result is not None + assert '"items"' in result + assert '"nested"' in result + + def test_escaped_quotes_in_strings(self): + """Handles escaped quotes within strings.""" + content = '''{"message": "He said \\"hello\\" to me", "count": 1}''' + result = MessageAdapter.extract_json(content) + assert result is not None + assert '\\"hello\\"' in result + + def test_braces_inside_strings(self): + """Ignores braces inside string values.""" + content = '{"code": "function() { return {}; }", "valid": true}' + result = MessageAdapter.extract_json(content) + assert result is not None + assert '"valid": true' in result + + def test_brackets_inside_strings(self): + """Ignores brackets inside string values.""" + content = '{"regex": "[a-z]+", "array": [1, 2, 3]}' + result = MessageAdapter.extract_json(content) + assert result is not None + assert '"array": [1, 2, 3]' in result + + def test_preamble_stripping(self): + """Removes common Claude preambles before JSON.""" + content = "Here's the JSON: {\"key\": \"value\"}" + result = MessageAdapter.extract_json(content) + assert result == '{"key": "value"}' + + def test_heres_the_response_preamble(self): + """Handles 'Here is the response:' preamble.""" + content = "Here is the response: {\"status\": \"ok\"}" + result = MessageAdapter.extract_json(content) + assert result == '{"status": "ok"}' + + def test_result_preamble(self): + """Handles 'Result:' preamble.""" + content = "Result: [1, 2, 3, 4, 5]" + result = MessageAdapter.extract_json(content) + assert result == '[1, 2, 3, 4, 5]' + + +class TestJsonExtractionMetadata: + """Test the extract_json_with_metadata method.""" + + def test_direct_extraction_method(self): + """Reports 'direct' method for pure JSON.""" + content = '{"pure": "json"}' + result = MessageAdapter.extract_json_with_metadata(content) + assert result.success is True + assert result.method == "direct" + assert result.content == content + + def test_preamble_removed_method(self): + """Reports 'preamble_removed' method when preamble stripped.""" + content = "Here's the JSON: {\"key\": \"value\"}" + result = MessageAdapter.extract_json_with_metadata(content) + assert result.success is True + assert result.method == "preamble_removed" + assert result.preamble_found == "Here's the JSON:" + + def test_code_block_method(self): + """Reports 'code_block' method for markdown extraction.""" + content = '''```json +{"extracted": true} +```''' + result = MessageAdapter.extract_json_with_metadata(content) + assert result.success is True + assert result.method == "code_block" + + def test_brace_match_method(self): + """Reports 'brace_match' for balanced extraction.""" + content = 'Some text {"embedded": true} more text' + result = MessageAdapter.extract_json_with_metadata(content) + assert result.success is True + assert result.method == "brace_match" + + def test_length_tracking(self): + """Tracks original and extracted lengths.""" + content = ' {"padded": true} ' + result = MessageAdapter.extract_json_with_metadata(content) + assert result.original_length == len(content) + assert result.extracted_length == len('{"padded": true}') + + def test_failure_reporting(self): + """Reports failure correctly for invalid content.""" + content = 'No JSON here at all!' + result = MessageAdapter.extract_json_with_metadata(content) + assert result.success is False + assert result.method == "failed" + assert result.content is None + + def test_empty_content(self): + """Handles empty content.""" + result = MessageAdapter.extract_json_with_metadata("") + assert result.success is False + assert result.method == "failed" + assert result.original_length == 0 + + +class TestEnforceJsonFormatWithMetadata: + """Test enforce_json_format_with_metadata method.""" + + def test_returns_tuple(self): + """Returns tuple of (content, metadata).""" + content = '{"key": "value"}' + result = MessageAdapter.enforce_json_format_with_metadata(content) + assert isinstance(result, tuple) + assert len(result) == 2 + + def test_metadata_dict_structure(self): + """Metadata dict contains expected keys.""" + content = '{"key": "value"}' + json_content, metadata = MessageAdapter.enforce_json_format_with_metadata(content) + assert "success" in metadata + assert "method" in metadata + assert "original_length" in metadata + assert "extracted_length" in metadata + assert "strict_mode" in metadata + + def test_strict_mode_in_metadata(self): + """Strict mode is reflected in metadata.""" + content = 'No JSON' + _, metadata_strict = MessageAdapter.enforce_json_format_with_metadata(content, strict=True) + _, metadata_non_strict = MessageAdapter.enforce_json_format_with_metadata(content, strict=False) + + assert metadata_strict["strict_mode"] is True + assert metadata_non_strict["strict_mode"] is False + + def test_fallback_used_on_failure(self): + """Reports fallback_used when extraction fails.""" + content = 'No JSON here!' + _, metadata = MessageAdapter.enforce_json_format_with_metadata(content, strict=True) + assert metadata.get("fallback_used") is True + assert metadata.get("fallback_value") == "[]" + + def test_preamble_in_metadata(self): + """Preamble is included in metadata when found.""" + content = "Here's the JSON: {\"key\": \"value\"}" + _, metadata = MessageAdapter.enforce_json_format_with_metadata(content) + assert metadata.get("preamble_found") == "Here's the JSON:" + + +class TestCommonPreambles: + """Test COMMON_PREAMBLES constant.""" + + def test_common_preambles_exists(self): + """COMMON_PREAMBLES constant exists.""" + assert hasattr(MessageAdapter, "COMMON_PREAMBLES") + + def test_common_preambles_is_list(self): + """COMMON_PREAMBLES is a list.""" + assert isinstance(MessageAdapter.COMMON_PREAMBLES, list) + + def test_common_preambles_not_empty(self): + """COMMON_PREAMBLES is not empty.""" + assert len(MessageAdapter.COMMON_PREAMBLES) > 0 + + def test_common_preambles_includes_heres(self): + """COMMON_PREAMBLES includes 'Here's the JSON:' variant.""" + preambles_lower = [p.lower() for p in MessageAdapter.COMMON_PREAMBLES] + assert any("here's the json" in p for p in preambles_lower) + + def test_common_preambles_includes_here_is(self): + """COMMON_PREAMBLES includes 'Here is the JSON:' variant.""" + preambles_lower = [p.lower() for p in MessageAdapter.COMMON_PREAMBLES] + assert any("here is the json" in p for p in preambles_lower) diff --git a/tests/test_request_cache_unit.py b/tests/test_request_cache_unit.py new file mode 100644 index 0000000..594c260 --- /dev/null +++ b/tests/test_request_cache_unit.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +Unit tests for request cache functionality. + +Tests the RequestCache class including caching, TTL, LRU eviction, +and statistics tracking. +""" + +import pytest +import time +from unittest.mock import patch + +from src.request_cache import RequestCache, CacheEntry + + +class TestRequestCache: + """Test RequestCache class.""" + + def test_cache_set_and_get(self): + """Basic set and get operations work.""" + cache = RequestCache(enabled=True, max_size=10, ttl_seconds=60) + request = {"model": "test", "messages": [{"role": "user", "content": "Hello"}]} + response = {"id": "123", "choices": [{"content": "Hi"}]} + + cache.set(request, response) + result = cache.get(request) + + assert result == response + + def test_cache_miss(self): + """Returns None for cache miss.""" + cache = RequestCache(enabled=True, max_size=10, ttl_seconds=60) + request = {"model": "test", "messages": [{"role": "user", "content": "Hello"}]} + + result = cache.get(request) + + assert result is None + + def test_cache_disabled(self): + """Returns None when cache is disabled.""" + cache = RequestCache(enabled=False, max_size=10, ttl_seconds=60) + request = {"model": "test", "messages": [{"role": "user", "content": "Hello"}]} + response = {"id": "123", "choices": [{"content": "Hi"}]} + + cache.set(request, response) + result = cache.get(request) + + assert result is None + + def test_cache_expiration(self): + """Entries expire after TTL.""" + cache = RequestCache(enabled=True, max_size=10, ttl_seconds=1) + request = {"model": "test", "messages": [{"role": "user", "content": "Hello"}]} + response = {"id": "123", "choices": [{"content": "Hi"}]} + + cache.set(request, response) + + # Should be present immediately + assert cache.get(request) == response + + # Wait for expiration + time.sleep(1.1) + + # Should be expired now + assert cache.get(request) is None + + def test_lru_eviction(self): + """LRU eviction when max_size is reached.""" + cache = RequestCache(enabled=True, max_size=2, ttl_seconds=60) + + request1 = {"model": "test", "messages": [{"role": "user", "content": "One"}]} + request2 = {"model": "test", "messages": [{"role": "user", "content": "Two"}]} + request3 = {"model": "test", "messages": [{"role": "user", "content": "Three"}]} + + cache.set(request1, {"id": "1"}) + cache.set(request2, {"id": "2"}) + + # Access request1 to make it more recently used + cache.get(request1) + + # Add request3, should evict request2 (least recently used) + cache.set(request3, {"id": "3"}) + + # request1 should still be present (was accessed) + assert cache.get(request1) is not None + # request3 should be present (just added) + assert cache.get(request3) is not None + # request2 should be evicted + assert cache.get(request2) is None + + def test_stats_tracking(self): + """Statistics are tracked correctly.""" + cache = RequestCache(enabled=True, max_size=10, ttl_seconds=60) + request = {"model": "test", "messages": [{"role": "user", "content": "Hello"}]} + response = {"id": "123", "choices": [{"content": "Hi"}]} + + # Initial stats + stats = cache.get_stats() + assert stats["hits"] == 0 + assert stats["misses"] == 0 + + # Miss + cache.get(request) + stats = cache.get_stats() + assert stats["misses"] == 1 + + # Set and hit + cache.set(request, response) + cache.get(request) + stats = cache.get_stats() + assert stats["hits"] == 1 + assert stats["misses"] == 1 + assert stats["hit_rate_percent"] == 50.0 + + def test_clear(self): + """Clear removes all entries.""" + cache = RequestCache(enabled=True, max_size=10, ttl_seconds=60) + + for i in range(5): + request = {"model": "test", "messages": [{"role": "user", "content": f"Msg {i}"}]} + cache.set(request, {"id": str(i)}) + + stats = cache.get_stats() + assert stats["current_size"] == 5 + + cleared = cache.clear() + + assert cleared == 5 + stats = cache.get_stats() + assert stats["current_size"] == 0 + + def test_hash_deterministic(self): + """Same request produces same hash.""" + cache = RequestCache(enabled=True) + + request1 = {"model": "test", "messages": [{"role": "user", "content": "Hello"}]} + request2 = {"model": "test", "messages": [{"role": "user", "content": "Hello"}]} + + hash1 = cache._compute_hash(request1) + hash2 = cache._compute_hash(request2) + + assert hash1 == hash2 + + def test_hash_ignores_irrelevant_fields(self): + """Hash ignores fields that don't affect response.""" + cache = RequestCache(enabled=True) + + request1 = { + "model": "test", + "messages": [{"role": "user", "content": "Hello"}], + "stream": False, + "session_id": "abc123", + } + request2 = { + "model": "test", + "messages": [{"role": "user", "content": "Hello"}], + "stream": True, # Different + "session_id": "xyz789", # Different + } + + hash1 = cache._compute_hash(request1) + hash2 = cache._compute_hash(request2) + + assert hash1 == hash2 + + def test_hash_differs_for_different_content(self): + """Different content produces different hashes.""" + cache = RequestCache(enabled=True) + + request1 = {"model": "test", "messages": [{"role": "user", "content": "Hello"}]} + request2 = {"model": "test", "messages": [{"role": "user", "content": "Goodbye"}]} + + hash1 = cache._compute_hash(request1) + hash2 = cache._compute_hash(request2) + + assert hash1 != hash2 + + def test_cleanup_expired(self): + """cleanup_expired removes expired entries.""" + cache = RequestCache(enabled=True, max_size=10, ttl_seconds=1) + + request1 = {"model": "test", "messages": [{"role": "user", "content": "One"}]} + request2 = {"model": "test", "messages": [{"role": "user", "content": "Two"}]} + + cache.set(request1, {"id": "1"}) + cache.set(request2, {"id": "2"}) + + # Wait for expiration + time.sleep(1.1) + + removed = cache.cleanup_expired() + + assert removed == 2 + assert cache.get_stats()["current_size"] == 0 + + def test_stats_include_config(self): + """Stats include configuration values.""" + cache = RequestCache(enabled=True, max_size=50, ttl_seconds=120) + stats = cache.get_stats() + + assert stats["enabled"] is True + assert stats["max_size"] == 50 + assert stats["ttl_seconds"] == 120 + + def test_enabled_property(self): + """enabled property reflects configuration.""" + cache_enabled = RequestCache(enabled=True) + cache_disabled = RequestCache(enabled=False) + + assert cache_enabled.enabled is True + assert cache_disabled.enabled is False + + +class TestCacheEntry: + """Test CacheEntry dataclass.""" + + def test_cache_entry_creation(self): + """CacheEntry can be created with required fields.""" + entry = CacheEntry( + response={"id": "test"}, + created_at=1000.0, + expires_at=1060.0, + ) + + assert entry.response == {"id": "test"} + assert entry.created_at == 1000.0 + assert entry.expires_at == 1060.0 + assert entry.hit_count == 0 # Default + + def test_cache_entry_hit_count(self): + """CacheEntry hit_count can be specified.""" + entry = CacheEntry( + response={"id": "test"}, + created_at=1000.0, + expires_at=1060.0, + hit_count=5, + ) + + assert entry.hit_count == 5 From 69d71c3cd09a8f8cd9ec790757338c5080a2583e Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 6 Feb 2026 21:08:46 -0800 Subject: [PATCH 06/38] feat: add dynamic model refresh endpoint - Add POST /v1/models/refresh to refresh models from Anthropic API at runtime - Add GET /v1/models/status for service observability (source, count, last refresh) - Track model source (api/fallback) and last refresh timestamp in ModelService - Add comprehensive unit tests for refresh functionality Version 2.4.1 --- CHANGELOG.md | 13 +++ src/__init__.py | 2 +- src/main.py | 35 +++++++ src/model_service.py | 48 +++++++++- tests/test_model_service_unit.py | 159 +++++++++++++++++++++++++++++++ 5 files changed, 254 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dcc424..43697a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to the Claude Code OpenAI Wrapper project will be documented The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.4.1] - 2026-02-06 + +### Added + +- **Dynamic Model Refresh**: New `POST /v1/models/refresh` endpoint to refresh models from Anthropic API at runtime without server restart +- **Model Service Status**: New `GET /v1/models/status` endpoint returning service status including source (api/fallback) and last refresh timestamp +- **Refresh Tracking**: ModelService now tracks `_last_refresh` timestamp and `_source` (api or fallback) for observability +- **Unit Tests**: Comprehensive tests for model refresh functionality including success/failure scenarios, timestamp tracking, and status reporting + +### Changed + +- **ModelService**: Enhanced with `refresh_models()` async method and `get_status()` method for runtime model management + ## [2.4.0] - 2026-02-04 ### Added diff --git a/src/__init__.py b/src/__init__.py index ec92ae7..37a0b52 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ """Claude Code OpenAI Wrapper - A FastAPI-based OpenAI-compatible API for Claude Code.""" -__version__ = "2.4.0" +__version__ = "2.4.1" diff --git a/src/main.py b/src/main.py index 458db53..5018057 100644 --- a/src/main.py +++ b/src/main.py @@ -1020,6 +1020,41 @@ async def list_models( } +@app.post("/v1/models/refresh") +@rate_limit_endpoint("general") +async def refresh_models_endpoint( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), +): + """Refresh the models list from the Anthropic API. + + Requires ANTHROPIC_API_KEY to be set. If the API call fails, + the existing cached models are preserved. + + Returns: + On success: {"success": true, "count": N, "source": "api", "models": [...]} + On failure: {"success": false, "message": "...", "current_count": N, "source": "..."} + """ + await verify_api_key(request, credentials) + result = await model_service.refresh_models() + return result + + +@app.get("/v1/models/status") +@rate_limit_endpoint("general") +async def get_models_status( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), +): + """Get model service status including source and last refresh time. + + Returns: + {"initialized": bool, "source": "api"|"fallback", "model_count": N, "last_refresh": timestamp|null} + """ + await verify_api_key(request, credentials) + return model_service.get_status() + + @app.post("/v1/compatibility") async def check_compatibility(request_body: ChatCompletionRequest): """Check OpenAI API compatibility for a request.""" diff --git a/src/model_service.py b/src/model_service.py index 7254937..5cdfebd 100644 --- a/src/model_service.py +++ b/src/model_service.py @@ -3,13 +3,15 @@ This service provides: - Dynamic model discovery from Anthropic API on startup +- Runtime model refresh via refresh_models() method - Graceful fallback to static CLAUDE_MODELS when API is unavailable -- Caching of fetched models for the session lifetime +- Caching of fetched models with refresh timestamp tracking """ import os +import time import logging -from typing import List, Optional +from typing import List, Optional, Dict, Any import httpx @@ -30,6 +32,8 @@ def __init__(self): self._cached_models: Optional[List[str]] = None self._http_client: Optional[httpx.AsyncClient] = None self._initialized: bool = False + self._last_refresh: Optional[float] = None + self._source: str = "fallback" # "api" or "fallback" async def initialize(self) -> None: """Called during app startup - fetch models from API.""" @@ -43,9 +47,12 @@ async def initialize(self) -> None: if fetched_models: self._cached_models = fetched_models + self._source = "api" + self._last_refresh = time.time() logger.info(f"Successfully fetched {len(fetched_models)} models from Anthropic API") else: self._cached_models = None + self._source = "fallback" logger.info("Using fallback static model list from constants") self._initialized = True @@ -57,6 +64,8 @@ async def shutdown(self) -> None: self._http_client = None self._cached_models = None self._initialized = False + self._last_refresh = None + self._source = "fallback" async def fetch_models_from_api(self) -> Optional[List[str]]: """ @@ -136,6 +145,41 @@ def is_initialized(self) -> bool: """Check if service has been initialized.""" return self._initialized + async def refresh_models(self) -> Dict[str, Any]: + """Force refresh models from Anthropic API. + + Returns a dict with refresh status and model information. + If the API call fails, existing cached models are preserved. + """ + models = await self.fetch_models_from_api() + if models: + self._cached_models = models + self._last_refresh = time.time() + self._source = "api" + logger.info(f"Refreshed {len(models)} models from Anthropic API") + return { + "success": True, + "count": len(models), + "source": "api", + "models": models, + } + else: + return { + "success": False, + "message": "API fetch failed, keeping existing models", + "current_count": len(self.get_models()), + "source": self._source, + } + + def get_status(self) -> Dict[str, Any]: + """Get service status including source and last refresh time.""" + return { + "initialized": self._initialized, + "source": self._source, + "model_count": len(self.get_models()), + "last_refresh": self._last_refresh, + } + # Global singleton instance model_service = ModelService() diff --git a/tests/test_model_service_unit.py b/tests/test_model_service_unit.py index 54ee3b7..13588be 100644 --- a/tests/test_model_service_unit.py +++ b/tests/test_model_service_unit.py @@ -6,6 +6,7 @@ with graceful fallback to static constants. """ +import time import pytest from unittest.mock import patch, AsyncMock, MagicMock import httpx @@ -253,3 +254,161 @@ async def test_fallback_on_api_failure(self): assert models == list(CLAUDE_MODELS) await service.shutdown() + + +class TestModelServiceRefresh: + """Tests for model refresh functionality.""" + + @pytest.fixture + def model_service(self): + """Create a fresh ModelService instance for each test.""" + return ModelService() + + @pytest.mark.asyncio + async def test_refresh_models_success(self, model_service): + """Refresh successfully updates cached models.""" + # First, initialize with some models + model_service._cached_models = ["old-model-1", "old-model-2"] + model_service._source = "api" + model_service._initialized = True + + new_models = ["new-model-1", "new-model-2", "new-model-3"] + + with patch.object( + model_service, "fetch_models_from_api", new_callable=AsyncMock + ) as mock: + mock.return_value = new_models + + result = await model_service.refresh_models() + + assert result["success"] is True + assert result["count"] == 3 + assert result["source"] == "api" + assert result["models"] == new_models + assert model_service._cached_models == new_models + assert model_service._source == "api" + assert model_service._last_refresh is not None + + @pytest.mark.asyncio + async def test_refresh_models_failure_preserves_existing(self, model_service): + """Refresh failure preserves existing cached models.""" + existing_models = ["existing-model-1", "existing-model-2"] + model_service._cached_models = existing_models + model_service._source = "api" + model_service._initialized = True + + with patch.object( + model_service, "fetch_models_from_api", new_callable=AsyncMock + ) as mock: + mock.return_value = None # API failed + + result = await model_service.refresh_models() + + assert result["success"] is False + assert "API fetch failed" in result["message"] + assert result["current_count"] == 2 + assert result["source"] == "api" + # Existing models should be preserved + assert model_service._cached_models == existing_models + + @pytest.mark.asyncio + async def test_refresh_models_updates_last_refresh_time(self, model_service): + """Refresh updates the last_refresh timestamp.""" + model_service._initialized = True + + before_time = time.time() + + with patch.object( + model_service, "fetch_models_from_api", new_callable=AsyncMock + ) as mock: + mock.return_value = ["model-1"] + + await model_service.refresh_models() + + after_time = time.time() + + assert model_service._last_refresh is not None + assert before_time <= model_service._last_refresh <= after_time + + @pytest.mark.asyncio + async def test_refresh_models_failure_does_not_update_timestamp(self, model_service): + """Refresh failure does not update last_refresh timestamp.""" + model_service._cached_models = ["model-1"] + model_service._last_refresh = 1000.0 # Some old timestamp + model_service._initialized = True + + with patch.object( + model_service, "fetch_models_from_api", new_callable=AsyncMock + ) as mock: + mock.return_value = None + + await model_service.refresh_models() + + # Timestamp should remain unchanged + assert model_service._last_refresh == 1000.0 + + def test_get_status_returns_correct_info(self, model_service): + """get_status returns correct service status.""" + model_service._initialized = True + model_service._source = "api" + model_service._cached_models = ["model-1", "model-2", "model-3"] + model_service._last_refresh = 1234567890.0 + + status = model_service.get_status() + + assert status["initialized"] is True + assert status["source"] == "api" + assert status["model_count"] == 3 + assert status["last_refresh"] == 1234567890.0 + + def test_get_status_fallback_source(self, model_service): + """get_status shows fallback source when not from API.""" + model_service._initialized = True + model_service._source = "fallback" + model_service._cached_models = None + model_service._last_refresh = None + + status = model_service.get_status() + + assert status["initialized"] is True + assert status["source"] == "fallback" + assert status["model_count"] == len(CLAUDE_MODELS) + assert status["last_refresh"] is None + + @pytest.mark.asyncio + async def test_initialize_sets_source_api_on_success(self, model_service): + """Initialize sets source to 'api' when fetch succeeds.""" + with patch.object( + model_service, "fetch_models_from_api", new_callable=AsyncMock + ) as mock: + mock.return_value = ["model-1", "model-2"] + + await model_service.initialize() + + assert model_service._source == "api" + assert model_service._last_refresh is not None + + @pytest.mark.asyncio + async def test_initialize_sets_source_fallback_on_failure(self, model_service): + """Initialize sets source to 'fallback' when fetch fails.""" + with patch.object( + model_service, "fetch_models_from_api", new_callable=AsyncMock + ) as mock: + mock.return_value = None + + await model_service.initialize() + + assert model_service._source == "fallback" + assert model_service._last_refresh is None + + @pytest.mark.asyncio + async def test_shutdown_resets_source_and_timestamp(self, model_service): + """Shutdown resets source and last_refresh.""" + model_service._source = "api" + model_service._last_refresh = 1234567890.0 + model_service._initialized = True + + await model_service.shutdown() + + assert model_service._source == "fallback" + assert model_service._last_refresh is None From 7bc615a1b46dd6e9a8799b021eef40da9252c23c Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Fri, 6 Feb 2026 21:49:03 -0800 Subject: [PATCH 07/38] feat: add auth method support for model refresh endpoint - Model refresh now respects CLAUDE_AUTH_METHOD configuration - Only 'anthropic' auth supports dynamic API fetch; others use static fallback - Added auth_method field to /v1/models/refresh and /v1/models/status responses - Updated CLAUDE_MODELS: added claude-opus-4-6, removed claude-opus-4-5-20250929 - Added model status/refresh endpoint cards to landing page UI - Comprehensive unit tests for all auth methods --- CHANGELOG.md | 16 ++ src/__init__.py | 2 +- src/constants.py | 11 +- src/main.py | 38 +++++ src/model_service.py | 67 +++++++- tests/test_model_service_unit.py | 284 ++++++++++++++++++++++++------- 6 files changed, 344 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43697a2..f23e5ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to the Claude Code OpenAI Wrapper project will be documented The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.4.2] - 2026-02-06 + +### Added + +- **Auth Method Awareness in Model Service**: Model refresh now respects `CLAUDE_AUTH_METHOD` configuration + - `anthropic` auth: Full support for dynamic model fetching from API + - `cli`, `bedrock`, `vertex` auth: Uses static fallback model list (API key not available) +- **Auth Method in Responses**: `/v1/models/refresh` and `/v1/models/status` responses now include `auth_method` field +- **Landing Page Updates**: Added `/v1/models/status` and `/v1/models/refresh` endpoint cards to the dashboard UI with interactive refresh button +- **Unit Tests**: Comprehensive tests for different auth method behaviors in model service + +### Changed + +- **Updated Model List**: Added `claude-opus-4-6` (latest), removed outdated `claude-opus-4-5-20250929` from static fallback list +- **Improved Error Messages**: Refresh endpoint now returns clear message when using non-anthropic auth methods + ## [2.4.1] - 2026-02-06 ### Added diff --git a/src/__init__.py b/src/__init__.py index 37a0b52..b07244b 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ """Claude Code OpenAI Wrapper - A FastAPI-based OpenAI-compatible API for Claude Code.""" -__version__ = "2.4.1" +__version__ = "2.4.2" diff --git a/src/constants.py b/src/constants.py index 5eb4149..5921e77 100644 --- a/src/constants.py +++ b/src/constants.py @@ -66,19 +66,20 @@ async def chat_endpoint(): ... ] # Claude Models -# Models supported by Claude Agent SDK (as of November 2025) +# Models supported by Claude Agent SDK (as of February 2026) # NOTE: Claude Agent SDK only supports Claude 4+ models, not Claude 3.x CLAUDE_MODELS = [ - # Claude 4.5 Family (Latest - Fall 2025) - RECOMMENDED - "claude-opus-4-5-20251101", # Latest Opus 4.5 - Most capable (November 2025) - "claude-opus-4-5-20250929", # Opus 4.5 - September version + # Claude 4.6 (Latest - 2026) + "claude-opus-4-6", # Latest Opus 4.6 + # Claude 4.5 Family (Fall 2025) + "claude-opus-4-5-20251101", # Opus 4.5 - November version "claude-sonnet-4-5-20250929", # Recommended - best coding model "claude-haiku-4-5-20251001", # Fast & cheap # Claude 4.1 "claude-opus-4-1-20250805", # Upgraded Opus 4 # Claude 4.0 Family (Original - May 2025) - "claude-opus-4-20250514", "claude-sonnet-4-20250514", + "claude-opus-4-20250514", # Claude 3.x Family - NOT SUPPORTED by Claude Agent SDK # These models work with Anthropic API but NOT with Claude Code # Uncomment only if using direct Anthropic API (not Claude Agent SDK) diff --git a/src/main.py b/src/main.py index 5018057..04f5e77 100644 --- a/src/main.py +++ b/src/main.py @@ -1504,6 +1504,19 @@ async def root(): document.getElementById('moon-icon').classList.toggle('hidden', !isDark); }} + async function refreshModels() {{ + const resultDiv = document.getElementById('data-models-refresh'); + resultDiv.innerHTML = 'Refreshing...'; + try {{ + const response = await fetch('/v1/models/refresh', {{ method: 'POST' }}); + const data = await response.json(); + const formatted = JSON.stringify(data, null, 2); + resultDiv.innerHTML = '
' + formatted + '
'; + }} catch (error) {{ + resultDiv.innerHTML = 'Error: ' + error.message + ''; + }} + }} + document.addEventListener('DOMContentLoaded', () => {{ const saved = localStorage.getItem('theme'); if (saved) {{ @@ -1612,6 +1625,31 @@ async def root(): +
+ + GET + /v1/models/status + Model service status + +
+ +
+
+
+ +
+ + POST + /v1/models/refresh + Refresh models from API + +
+

Requires CLAUDE_AUTH_METHOD=api_key with ANTHROPIC_API_KEY set.

+ +
+
+
+
GET diff --git a/src/model_service.py b/src/model_service.py index 5cdfebd..4e6f999 100644 --- a/src/model_service.py +++ b/src/model_service.py @@ -6,6 +6,7 @@ - Runtime model refresh via refresh_models() method - Graceful fallback to static CLAUDE_MODELS when API is unavailable - Caching of fetched models with refresh timestamp tracking +- Auth method awareness (only fetches from API for 'anthropic' auth) """ import os @@ -16,6 +17,7 @@ import httpx from src.constants import CLAUDE_MODELS +from src.auth import auth_manager logger = logging.getLogger(__name__) @@ -69,7 +71,44 @@ async def shutdown(self) -> None: async def fetch_models_from_api(self) -> Optional[List[str]]: """ - Fetch models from Anthropic API. + Fetch models based on configured auth method. + + Only the 'anthropic' auth method supports dynamic model fetching. + Other auth methods (cli, bedrock, vertex) use static model lists. + + Returns list of model IDs on success, None on failure/unsupported. + """ + auth_method = auth_manager.auth_method + + if auth_method == "anthropic": + # Use ANTHROPIC_API_KEY for direct API call + api_key = os.getenv("ANTHROPIC_API_KEY") + if not api_key: + logger.debug("ANTHROPIC_API_KEY not set, using fallback") + return None + return await self._fetch_with_api_key(api_key) + + elif auth_method == "claude_cli": + # CLI auth doesn't expose API key - use fallback + logger.info("CLI auth method configured - using static model list") + return None + + elif auth_method == "bedrock": + # Bedrock uses different model naming, use fallback + logger.info("Bedrock auth method - using static model list") + return None + + elif auth_method == "vertex": + # Vertex uses different model naming, use fallback + logger.info("Vertex auth method - using static model list") + return None + + logger.debug(f"Unknown auth method '{auth_method}', using fallback") + return None + + async def _fetch_with_api_key(self, api_key: str) -> Optional[List[str]]: + """ + Fetch models from Anthropic API using API key. GET https://api.anthropic.com/v1/models Headers: @@ -78,12 +117,6 @@ async def fetch_models_from_api(self) -> Optional[List[str]]: Returns list of model IDs on success, None on failure. """ - api_key = os.getenv("ANTHROPIC_API_KEY") - - if not api_key: - logger.debug("ANTHROPIC_API_KEY not set, skipping API model fetch") - return None - if not self._http_client: self._http_client = httpx.AsyncClient(timeout=MODEL_FETCH_TIMEOUT) @@ -150,7 +183,22 @@ async def refresh_models(self) -> Dict[str, Any]: Returns a dict with refresh status and model information. If the API call fails, existing cached models are preserved. + + Note: Only 'anthropic' auth method supports dynamic refresh. + Other auth methods will return success=False with explanation. """ + auth_method = auth_manager.auth_method + + # Check if auth method supports dynamic refresh + if auth_method != "anthropic": + return { + "success": False, + "message": f"Dynamic refresh requires ANTHROPIC_API_KEY. Current auth: {auth_method}", + "current_count": len(self.get_models()), + "source": self._source, + "auth_method": auth_method, + } + models = await self.fetch_models_from_api() if models: self._cached_models = models @@ -162,6 +210,7 @@ async def refresh_models(self) -> Dict[str, Any]: "count": len(models), "source": "api", "models": models, + "auth_method": auth_method, } else: return { @@ -169,15 +218,17 @@ async def refresh_models(self) -> Dict[str, Any]: "message": "API fetch failed, keeping existing models", "current_count": len(self.get_models()), "source": self._source, + "auth_method": auth_method, } def get_status(self) -> Dict[str, Any]: - """Get service status including source and last refresh time.""" + """Get service status including source, auth method, and last refresh time.""" return { "initialized": self._initialized, "source": self._source, "model_count": len(self.get_models()), "last_refresh": self._last_refresh, + "auth_method": auth_manager.auth_method, } diff --git a/tests/test_model_service_unit.py b/tests/test_model_service_unit.py index 13588be..5bc80d7 100644 --- a/tests/test_model_service_unit.py +++ b/tests/test_model_service_unit.py @@ -3,12 +3,13 @@ Unit tests for src/model_service.py Tests the ModelService class that fetches models from Anthropic API -with graceful fallback to static constants. +with graceful fallback to static constants. Includes tests for +different authentication methods (anthropic, cli, bedrock, vertex). """ import time import pytest -from unittest.mock import patch, AsyncMock, MagicMock +from unittest.mock import patch, AsyncMock, MagicMock, PropertyMock import httpx from src.model_service import ModelService, MODEL_FETCH_TIMEOUT @@ -25,7 +26,7 @@ def model_service(self): @pytest.mark.asyncio async def test_fetch_models_success(self, model_service): - """Successfully fetches models from API.""" + """Successfully fetches models from API with anthropic auth.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { @@ -35,11 +36,13 @@ async def test_fetch_models_success(self, model_service): ] } - with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): - with patch.object(model_service, "_http_client") as mock_client: - mock_client.get = AsyncMock(return_value=mock_response) + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "anthropic" + with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): + with patch.object(model_service, "_http_client") as mock_client: + mock_client.get = AsyncMock(return_value=mock_response) - result = await model_service.fetch_models_from_api() + result = await model_service.fetch_models_from_api() assert result is not None assert len(result) == 2 @@ -49,11 +52,13 @@ async def test_fetch_models_success(self, model_service): @pytest.mark.asyncio async def test_fetch_models_timeout(self, model_service): """Returns None on timeout, allowing fallback to constants.""" - with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): - with patch.object(model_service, "_http_client") as mock_client: - mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout")) + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "anthropic" + with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): + with patch.object(model_service, "_http_client") as mock_client: + mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout")) - result = await model_service.fetch_models_from_api() + result = await model_service.fetch_models_from_api() assert result is None @@ -63,11 +68,13 @@ async def test_fetch_models_auth_error(self, model_service): mock_response = MagicMock() mock_response.status_code = 401 - with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "invalid-key"}): - with patch.object(model_service, "_http_client") as mock_client: - mock_client.get = AsyncMock(return_value=mock_response) + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "anthropic" + with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "invalid-key"}): + with patch.object(model_service, "_http_client") as mock_client: + mock_client.get = AsyncMock(return_value=mock_response) - result = await model_service.fetch_models_from_api() + result = await model_service.fetch_models_from_api() assert result is None @@ -77,37 +84,43 @@ async def test_fetch_models_rate_limited(self, model_service): mock_response = MagicMock() mock_response.status_code = 429 - with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): - with patch.object(model_service, "_http_client") as mock_client: - mock_client.get = AsyncMock(return_value=mock_response) + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "anthropic" + with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): + with patch.object(model_service, "_http_client") as mock_client: + mock_client.get = AsyncMock(return_value=mock_response) - result = await model_service.fetch_models_from_api() + result = await model_service.fetch_models_from_api() assert result is None @pytest.mark.asyncio async def test_fetch_models_network_error(self, model_service): """Returns None on network error, allowing fallback.""" - with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): - with patch.object(model_service, "_http_client") as mock_client: - mock_client.get = AsyncMock( - side_effect=httpx.RequestError("connection failed") - ) + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "anthropic" + with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): + with patch.object(model_service, "_http_client") as mock_client: + mock_client.get = AsyncMock( + side_effect=httpx.RequestError("connection failed") + ) - result = await model_service.fetch_models_from_api() + result = await model_service.fetch_models_from_api() assert result is None @pytest.mark.asyncio async def test_fetch_models_no_api_key(self, model_service): - """Returns None when no API key is set.""" - with patch.dict("os.environ", {}, clear=True): - # Ensure ANTHROPIC_API_KEY is not set - import os - if "ANTHROPIC_API_KEY" in os.environ: - del os.environ["ANTHROPIC_API_KEY"] + """Returns None when no API key is set (anthropic auth).""" + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "anthropic" + with patch.dict("os.environ", {}, clear=True): + # Ensure ANTHROPIC_API_KEY is not set + import os + if "ANTHROPIC_API_KEY" in os.environ: + del os.environ["ANTHROPIC_API_KEY"] - result = await model_service.fetch_models_from_api() + result = await model_service.fetch_models_from_api() assert result is None @@ -118,11 +131,13 @@ async def test_fetch_models_empty_response(self, model_service): mock_response.status_code = 200 mock_response.json.return_value = {"data": []} - with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): - with patch.object(model_service, "_http_client") as mock_client: - mock_client.get = AsyncMock(return_value=mock_response) + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "anthropic" + with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "test-key"}): + with patch.object(model_service, "_http_client") as mock_client: + mock_client.get = AsyncMock(return_value=mock_response) - result = await model_service.fetch_models_from_api() + result = await model_service.fetch_models_from_api() assert result is None @@ -266,7 +281,7 @@ def model_service(self): @pytest.mark.asyncio async def test_refresh_models_success(self, model_service): - """Refresh successfully updates cached models.""" + """Refresh successfully updates cached models with anthropic auth.""" # First, initialize with some models model_service._cached_models = ["old-model-1", "old-model-2"] model_service._source = "api" @@ -274,17 +289,20 @@ async def test_refresh_models_success(self, model_service): new_models = ["new-model-1", "new-model-2", "new-model-3"] - with patch.object( - model_service, "fetch_models_from_api", new_callable=AsyncMock - ) as mock: - mock.return_value = new_models + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "anthropic" + with patch.object( + model_service, "fetch_models_from_api", new_callable=AsyncMock + ) as mock: + mock.return_value = new_models - result = await model_service.refresh_models() + result = await model_service.refresh_models() assert result["success"] is True assert result["count"] == 3 assert result["source"] == "api" assert result["models"] == new_models + assert result["auth_method"] == "anthropic" assert model_service._cached_models == new_models assert model_service._source == "api" assert model_service._last_refresh is not None @@ -297,17 +315,20 @@ async def test_refresh_models_failure_preserves_existing(self, model_service): model_service._source = "api" model_service._initialized = True - with patch.object( - model_service, "fetch_models_from_api", new_callable=AsyncMock - ) as mock: - mock.return_value = None # API failed + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "anthropic" + with patch.object( + model_service, "fetch_models_from_api", new_callable=AsyncMock + ) as mock: + mock.return_value = None # API failed - result = await model_service.refresh_models() + result = await model_service.refresh_models() assert result["success"] is False assert "API fetch failed" in result["message"] assert result["current_count"] == 2 assert result["source"] == "api" + assert result["auth_method"] == "anthropic" # Existing models should be preserved assert model_service._cached_models == existing_models @@ -318,12 +339,14 @@ async def test_refresh_models_updates_last_refresh_time(self, model_service): before_time = time.time() - with patch.object( - model_service, "fetch_models_from_api", new_callable=AsyncMock - ) as mock: - mock.return_value = ["model-1"] + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "anthropic" + with patch.object( + model_service, "fetch_models_from_api", new_callable=AsyncMock + ) as mock: + mock.return_value = ["model-1"] - await model_service.refresh_models() + await model_service.refresh_models() after_time = time.time() @@ -337,29 +360,34 @@ async def test_refresh_models_failure_does_not_update_timestamp(self, model_serv model_service._last_refresh = 1000.0 # Some old timestamp model_service._initialized = True - with patch.object( - model_service, "fetch_models_from_api", new_callable=AsyncMock - ) as mock: - mock.return_value = None + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "anthropic" + with patch.object( + model_service, "fetch_models_from_api", new_callable=AsyncMock + ) as mock: + mock.return_value = None - await model_service.refresh_models() + await model_service.refresh_models() # Timestamp should remain unchanged assert model_service._last_refresh == 1000.0 def test_get_status_returns_correct_info(self, model_service): - """get_status returns correct service status.""" + """get_status returns correct service status including auth_method.""" model_service._initialized = True model_service._source = "api" model_service._cached_models = ["model-1", "model-2", "model-3"] model_service._last_refresh = 1234567890.0 - status = model_service.get_status() + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "anthropic" + status = model_service.get_status() assert status["initialized"] is True assert status["source"] == "api" assert status["model_count"] == 3 assert status["last_refresh"] == 1234567890.0 + assert status["auth_method"] == "anthropic" def test_get_status_fallback_source(self, model_service): """get_status shows fallback source when not from API.""" @@ -368,12 +396,15 @@ def test_get_status_fallback_source(self, model_service): model_service._cached_models = None model_service._last_refresh = None - status = model_service.get_status() + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "claude_cli" + status = model_service.get_status() assert status["initialized"] is True assert status["source"] == "fallback" assert status["model_count"] == len(CLAUDE_MODELS) assert status["last_refresh"] is None + assert status["auth_method"] == "claude_cli" @pytest.mark.asyncio async def test_initialize_sets_source_api_on_success(self, model_service): @@ -412,3 +443,136 @@ async def test_shutdown_resets_source_and_timestamp(self, model_service): assert model_service._source == "fallback" assert model_service._last_refresh is None + + +class TestModelServiceAuthMethods: + """Tests for different authentication method behaviors.""" + + @pytest.fixture + def model_service(self): + """Create a fresh ModelService instance for each test.""" + return ModelService() + + @pytest.mark.asyncio + async def test_fetch_models_cli_auth_returns_none(self, model_service): + """CLI auth method returns None (uses static fallback).""" + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "claude_cli" + + result = await model_service.fetch_models_from_api() + + assert result is None + + @pytest.mark.asyncio + async def test_fetch_models_bedrock_auth_returns_none(self, model_service): + """Bedrock auth method returns None (uses static fallback).""" + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "bedrock" + + result = await model_service.fetch_models_from_api() + + assert result is None + + @pytest.mark.asyncio + async def test_fetch_models_vertex_auth_returns_none(self, model_service): + """Vertex auth method returns None (uses static fallback).""" + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "vertex" + + result = await model_service.fetch_models_from_api() + + assert result is None + + @pytest.mark.asyncio + async def test_fetch_models_unknown_auth_returns_none(self, model_service): + """Unknown auth method returns None (uses static fallback).""" + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "unknown_method" + + result = await model_service.fetch_models_from_api() + + assert result is None + + @pytest.mark.asyncio + async def test_refresh_models_cli_auth_fails(self, model_service): + """Refresh with CLI auth returns failure with auth_method in response.""" + model_service._cached_models = ["model-1"] + model_service._source = "fallback" + model_service._initialized = True + + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "claude_cli" + + result = await model_service.refresh_models() + + assert result["success"] is False + assert "Dynamic refresh requires ANTHROPIC_API_KEY" in result["message"] + assert result["auth_method"] == "claude_cli" + assert result["current_count"] == 1 + + @pytest.mark.asyncio + async def test_refresh_models_bedrock_auth_fails(self, model_service): + """Refresh with Bedrock auth returns failure with auth_method in response.""" + model_service._cached_models = None + model_service._source = "fallback" + model_service._initialized = True + + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "bedrock" + + result = await model_service.refresh_models() + + assert result["success"] is False + assert "Dynamic refresh requires ANTHROPIC_API_KEY" in result["message"] + assert result["auth_method"] == "bedrock" + assert result["current_count"] == len(CLAUDE_MODELS) + + @pytest.mark.asyncio + async def test_refresh_models_vertex_auth_fails(self, model_service): + """Refresh with Vertex auth returns failure with auth_method in response.""" + model_service._cached_models = None + model_service._source = "fallback" + model_service._initialized = True + + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "vertex" + + result = await model_service.refresh_models() + + assert result["success"] is False + assert "Dynamic refresh requires ANTHROPIC_API_KEY" in result["message"] + assert result["auth_method"] == "vertex" + assert result["current_count"] == len(CLAUDE_MODELS) + + def test_get_status_includes_auth_method_cli(self, model_service): + """get_status includes auth_method for CLI auth.""" + model_service._initialized = True + model_service._source = "fallback" + + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "claude_cli" + status = model_service.get_status() + + assert status["auth_method"] == "claude_cli" + + def test_get_status_includes_auth_method_bedrock(self, model_service): + """get_status includes auth_method for Bedrock auth.""" + model_service._initialized = True + model_service._source = "fallback" + + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "bedrock" + status = model_service.get_status() + + assert status["auth_method"] == "bedrock" + + def test_get_status_includes_auth_method_vertex(self, model_service): + """get_status includes auth_method for Vertex auth.""" + model_service._initialized = True + model_service._source = "fallback" + + with patch("src.model_service.auth_manager") as mock_auth: + mock_auth.auth_method = "vertex" + status = model_service.get_status() + + assert status["auth_method"] == "vertex" From 4ba088fb49d35cbbe9632051de80cbd221af70ee Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Tue, 31 Mar 2026 19:52:55 -0400 Subject: [PATCH 08/38] feat: update models, tools, pricing, and add retry/cost tracking (v2.5.0) - Add model metadata (context windows, output limits) and pricing from source - Add claude-sonnet-4-6 and re-enable 3.x models confirmed supported - Expand tool registry from 15 to 33 tools matching actual inventory - Add retry module with exponential backoff and Opus-to-Sonnet fallback - Add cost tracker with per-session accumulation and auto-cleanup - Add X-Claude-Effort and X-Claude-Thinking header support - Add model-specific max_tokens validation - Extract shared options-building helper for streaming/non-streaming paths - Rewrite README, trim historical migration docs --- CHANGELOG.md | 31 ++ README.md | 765 +++++++---------------------- docs/MIGRATION_STATUS.md | 130 +---- docs/UPGRADE_PLAN.md | 823 +------------------------------- pyproject.toml | 2 +- src/__init__.py | 2 +- src/claude_cli.py | 84 +++- src/constants.py | 129 +++-- src/cost_tracker.py | 175 +++++++ src/main.py | 151 +++--- src/parameter_validator.py | 49 +- src/retry.py | 128 +++++ src/tool_manager.py | 178 ++++++- tests/test_cost_tracker_unit.py | 120 +++++ tests/test_retry_unit.py | 146 ++++++ tests/test_sdk_migration.py | 2 +- 16 files changed, 1264 insertions(+), 1651 deletions(-) create mode 100644 src/cost_tracker.py create mode 100644 src/retry.py create mode 100644 tests/test_cost_tracker_unit.py create mode 100644 tests/test_retry_unit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f23e5ac..b7f02c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to the Claude Code OpenAI Wrapper project will be documented The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.5.0] - 2026-03-31 + +### Added + +- **Model Metadata**: Per-model context window sizes, default/max output token limits sourced from open-sourced Claude Code CLI +- **Model Pricing Data**: Per-model pricing (input, output, cache read/write) for all supported models, sourced from Claude Code source +- **Cost Tracker** (`src/cost_tracker.py`): New module for per-request and per-session cost estimation using authoritative pricing data + - Tracks input/output tokens, cache tokens, web search requests + - Per-model usage breakdown per session +- **Retry Logic** (`src/retry.py`): New module implementing retry with exponential backoff and jitter + - Configurable max retries (default 10), base delay (500ms), max delay (30s) + - Model fallback: after 3 consecutive 529 (overloaded) errors, falls back from Opus to Sonnet + - Retryable status codes: 429, 529, 5xx, 401, 400 +- **New Tools**: Added 18 tools to match Claude Code's actual tool inventory: + - `Agent` (with `Task` as backward-compatible alias) + - `SendMessage`, `TaskCreate`, `TaskUpdate`, `TaskGet`, `TaskList`, `TaskOutput`, `TaskStop` + - `EnterPlanMode`, `ExitPlanMode`, `EnterWorktree`, `ExitWorktree` + - `ToolSearch`, `AskUserQuestion` + - `CronCreate`, `CronDelete`, `CronList`, `RemoteTrigger` +- **Effort Level Support**: New `X-Claude-Effort` header (low, medium, high, max) +- **Thinking Mode Support**: New `X-Claude-Thinking` header (adaptive, enabled, disabled) +- **Max Tokens Validation**: Model-specific max_tokens validation and capping via `ParameterValidator.validate_max_tokens()` +- **Model Fallback Map**: Automatic Opus-to-Sonnet fallback mapping for overload resilience + +### Changed + +- **Model List Updated**: Added `claude-sonnet-4-6` (latest) and re-added Claude 3.x models (`claude-3-7-sonnet-20250219`, `claude-3-5-sonnet-20241022`, `claude-3-5-haiku-20241022`) which are confirmed supported by Claude Code +- **Default Model**: Changed from `claude-sonnet-4-5-20250929` to `claude-sonnet-4-6` (latest Sonnet) +- **Tool Safety Classifications**: Updated based on Claude Code source -- `Bash` now marked as requiring permissions, `Agent`/`SendMessage`/`RemoteTrigger` marked as unsafe +- **Default Disallowed Tools**: Added `SendMessage` and `RemoteTrigger` to default disallow list + ## [2.4.2] - 2026-02-06 ### Added diff --git a/README.md b/README.md index 47c67e3..e53f608 100644 --- a/README.md +++ b/README.md @@ -1,327 +1,139 @@ # Claude Code OpenAI API Wrapper -An OpenAI API-compatible wrapper for Claude Code, allowing you to use Claude Code with any OpenAI client library. **Now powered by the official Claude Agent SDK v0.1.18** with enhanced authentication and features. +An OpenAI API-compatible wrapper for Claude Code, powered by the Claude Agent SDK v0.1.18. Use Claude Code with any OpenAI client library. ## Version -**Current Version:** 2.2.0 -- **Interactive Landing Page:** API explorer at root URL with live endpoint testing -- **Anthropic Messages API:** Native `/v1/messages` endpoint alongside OpenAI format -- **Explicit Auth Selection:** New `CLAUDE_AUTH_METHOD` env var for auth control -- **Tool Execution Fix:** `enable_tools: true` now properly enables Claude Code tools +**Current:** 2.5.0 -**Upgrading from v1.x?** -1. Pull latest code: `git pull origin main` -2. Update dependencies: `poetry install` -3. Restart server - that's it! +What's new: +- Model list updated from open-sourced Claude Code source (11 models, per-model metadata and pricing) +- 33 tools tracked (up from 15), matching Claude Code's actual inventory +- Cost tracking with authoritative per-model pricing +- Retry logic with exponential backoff and model fallback +- `X-Claude-Effort` and `X-Claude-Thinking` headers for fine-grained control +- Model-specific `max_tokens` validation -**Migration Resources:** -- [MIGRATION_STATUS.md](./MIGRATION_STATUS.md) - Detailed v2.0.0 migration status -- [UPGRADE_PLAN.md](./UPGRADE_PLAN.md) - Comprehensive migration strategy and technical details +See [CHANGELOG.md](./CHANGELOG.md) for full history. ## Status -🎉 **Production Ready** - All core features working and tested: -- ✅ Chat completions endpoint with **official Claude Agent SDK v0.1.18** -- ✅ **Anthropic Messages API** (`/v1/messages`) for native compatibility -- ✅ Streaming and non-streaming responses -- ✅ Full OpenAI SDK compatibility -- ✅ **Interactive landing page** with API explorer -- ✅ **Multi-provider authentication** (API key, Bedrock, Vertex AI, CLI auth) -- ✅ **System prompt support** via SDK options -- ✅ Model selection support with validation -- ✅ **Fast by default** - Tools disabled for OpenAI compatibility (5-10x faster) -- ✅ Optional tool usage (Read, Write, Bash, etc.) when explicitly enabled -- ✅ **Real-time cost and token tracking** from SDK -- ✅ **Session continuity** with conversation history across requests -- ✅ **Session management endpoints** for full session control -- ✅ Health, auth status, and models endpoints -- ✅ **Development mode** with auto-reload - -## Features - -### 🔥 **Core API Compatibility** -- OpenAI-compatible `/v1/chat/completions` endpoint -- Anthropic-compatible `/v1/messages` endpoint -- Support for both streaming and non-streaming responses -- Compatible with OpenAI Python SDK and all OpenAI client libraries -- Automatic model validation and selection - -### 🛠 **Claude Agent SDK Integration** -- **Official Claude Agent SDK** integration (v0.1.18) 🆕 -- **Real-time cost tracking** - actual costs from SDK metadata -- **Accurate token counting** - input/output tokens from SDK -- **Session management** - proper session IDs and continuity -- **Enhanced error handling** with detailed authentication diagnostics -- **Modern SDK features** - Latest capabilities and improvements - -### 🔐 **Multi-Provider Authentication** -- **Automatic detection** of authentication method -- **Claude CLI auth** - works with existing `claude auth` setup -- **Direct API key** - `ANTHROPIC_API_KEY` environment variable -- **AWS Bedrock** - enterprise authentication with AWS credentials -- **Google Vertex AI** - GCP authentication support - -### ⚡ **Advanced Features** -- **System prompt support** via SDK options -- **Optional tool usage** - Enable Claude Code tools (Read, Write, Bash, etc.) when needed -- **Fast default mode** - Tools disabled by default for OpenAI API compatibility -- **Development mode** with auto-reload (`uvicorn --reload`) -- **Interactive API key protection** - Optional security with auto-generated tokens -- **Comprehensive logging** and debugging capabilities - -### 🌐 **Interactive Landing Page** -- **API Explorer** at root URL (`http://localhost:8000/`) -- **Live endpoint testing** - Expandable accordions fetch real-time data -- **Light/dark theme toggle** - Persists preference in localStorage -- **Copy-to-clipboard** - One-click copy for Quick Start commands -- **Version badge** and GitHub link +Production ready. Core features working and tested: +- Chat completions with Claude Agent SDK v0.1.18 +- Anthropic Messages API (`/v1/messages`) +- Streaming and non-streaming responses +- OpenAI SDK compatibility +- Multi-provider auth (API key, Bedrock, Vertex AI, CLI) +- System prompt support, model selection with validation +- Tools disabled by default for speed; opt-in with `enable_tools: true` +- Cost and token tracking +- Session continuity across requests +- Interactive landing page with API explorer ## Quick Start -Get started in under 2 minutes: - ```bash -# 1. Clone and setup the wrapper -git clone https://github.com/RichardAtCT/claude-code-openai-wrapper +# Clone and install +git clone https://github.com/ttlequals0/claude-code-openai-wrapper cd claude-code-openai-wrapper -poetry install # Installs SDK with bundled Claude Code CLI +poetry install -# 2. Authenticate (choose one method) -export ANTHROPIC_API_KEY=your-api-key # Recommended -# OR use CLI auth: claude auth login +# Authenticate (pick one) +export ANTHROPIC_API_KEY=your-api-key +# or: claude auth login -# 3. Start the server +# Start poetry run uvicorn src.main:app --reload --port 8000 -# 4. Test it works +# Test poetry run python test_endpoints.py ``` -🎉 **That's it!** Your OpenAI-compatible Claude Code API is running on `http://localhost:8000` +Your OpenAI-compatible Claude Code API is now running on `http://localhost:8000`. ## Prerequisites -1. **Python 3.10+**: Required for the server (supports Python 3.10, 3.11, 3.12, 3.13) - -2. **Poetry**: For dependency management +1. **Python 3.10+** +2. **Poetry** for dependency management: ```bash - # Install Poetry (if not already installed) curl -sSL https://install.python-poetry.org | python3 - ``` +3. **Authentication** (pick one): + - `export ANTHROPIC_API_KEY=your-api-key` (recommended) + - `claude auth login` (CLI auth) + - AWS Bedrock or Google Vertex AI (see Configuration) -3. **Authentication**: Choose one method: - - **Option A**: Set environment variable (Recommended) - ```bash - export ANTHROPIC_API_KEY=your-api-key - ``` - - **Option B**: Authenticate via CLI - ```bash - claude auth login - ``` - - **Option C**: Use AWS Bedrock or Google Vertex AI (see Configuration section) - -> **Note:** The Claude Code CLI is bundled with the SDK (v0.1.18+). No separate Node.js or npm installation required! +The Claude Code CLI is bundled with the SDK. No separate Node.js or npm install needed. ## Installation -1. Clone the repository: - ```bash - git clone https://github.com/RichardAtCT/claude-code-openai-wrapper - cd claude-code-openai-wrapper - ``` - -2. Install dependencies with Poetry: - ```bash - poetry install - ``` - - This will create a virtual environment and install all dependencies. - -3. Configure environment: - ```bash - cp .env.example .env - # Edit .env with your preferences - ``` +```bash +git clone https://github.com/RichardAtCT/claude-code-openai-wrapper +cd claude-code-openai-wrapper +poetry install +cp .env.example .env # Edit with your preferences +``` ## Configuration -Edit the `.env` file: +Edit `.env`: ```env -# Claude CLI path (usually just "claude") -CLAUDE_CLI_PATH=claude - -# Explicit authentication method (optional) -# Options: cli, api_key, bedrock, vertex -# If not set, auto-detects based on available credentials -# CLAUDE_AUTH_METHOD=cli +# Auth (optional - auto-detects if not set) +# CLAUDE_AUTH_METHOD=cli|api_key|bedrock|vertex -# Optional API key for client authentication -# If not set, server will prompt for interactive API key protection on startup +# Optional client API key protection # API_KEY=your-optional-api-key -# Server port PORT=8000 - -# Timeout in milliseconds -MAX_TIMEOUT=600000 - -# CORS origins -CORS_ORIGINS=["*"] - -# Working directory for Claude Code (optional) -# If not set, uses an isolated temporary directory for security -# CLAUDE_CWD=/path/to/your/workspace +MAX_TIMEOUT=600000 # milliseconds +# CLAUDE_CWD=/path/to/workspace # defaults to isolated temp dir ``` -### 📁 **Working Directory Configuration** +### Working Directory -By default, Claude Code runs in an **isolated temporary directory** to prevent it from accessing the wrapper's source code. This enhances security by ensuring Claude Code only has access to the workspace you intend. +By default, Claude Code runs in an isolated temporary directory so it can't access the wrapper's own source. Set `CLAUDE_CWD` to point it at a specific project instead. -**Configuration Options:** +### API Key Protection -1. **Default (Recommended)**: Automatically creates a temporary isolated workspace - ```bash - # No configuration needed - secure by default - poetry run python main.py - ``` - -2. **Custom Directory**: Set a specific workspace directory - ```bash - export CLAUDE_CWD=/path/to/your/project - poetry run python main.py - ``` +If no `API_KEY` is set, the server prompts on startup whether to generate one. Useful for remote access over VPN or Tailscale. -3. **Via .env file**: Add to your `.env` file - ```env - CLAUDE_CWD=/home/user/my-workspace - ``` +### Rate Limiting -**Important Notes:** -- The temporary directory is automatically cleaned up when the server stops -- This prevents Claude Code from accidentally modifying the wrapper's own code -- Cross-platform compatible (Windows, macOS, Linux) +Per-IP rate limiting is built in. Defaults: -### 🔐 **API Security Configuration** +| Endpoint | Limit | +|----------|-------| +| `/v1/chat/completions` | 10/min | +| `/v1/debug/request` | 2/min | +| `/v1/auth/status` | 10/min | +| `/health` | 30/min | -The server supports **interactive API key protection** for secure remote access: +Configure via environment variables: `RATE_LIMIT_ENABLED`, `RATE_LIMIT_CHAT_PER_MINUTE`, etc. -1. **No API key set**: Server prompts "Enable API key protection? (y/N)" on startup - - Choose **No** (default): Server runs without authentication - - Choose **Yes**: Server generates and displays a secure API key - -2. **Environment API key set**: Uses the configured `API_KEY` without prompting +## Running the Server ```bash -# Example: Interactive protection enabled -poetry run python main.py - -# Output: -# ============================================================ -# 🔐 API Endpoint Security Configuration -# ============================================================ -# Would you like to protect your API endpoint with an API key? -# This adds a security layer when accessing your server remotely. -# -# Enable API key protection? (y/N): y -# -# 🔑 API Key Generated! -# ============================================================ -# API Key: Xf8k2mN9-vLp3qR5_zA7bW1cE4dY6sT0uI -# ============================================================ -# 📋 IMPORTANT: Save this key - you'll need it for API calls! -# Example usage: -# curl -H "Authorization: Bearer Xf8k2mN9-vLp3qR5_zA7bW1cE4dY6sT0uI" \ -# http://localhost:8000/v1/models -# ============================================================ -``` - -**Perfect for:** -- 🏠 **Local development** - No authentication needed -- 🌐 **Remote access** - Secure with generated tokens -- 🔒 **VPN/Tailscale** - Add security layer for remote endpoints - -### 🛡️ **Rate Limiting** - -Built-in rate limiting protects against abuse and ensures fair usage: - -- **Chat Completions** (`/v1/chat/completions`): 10 requests/minute -- **Debug Requests** (`/v1/debug/request`): 2 requests/minute -- **Auth Status** (`/v1/auth/status`): 10 requests/minute -- **Health Check** (`/health`): 30 requests/minute - -Rate limits are applied per IP address using a fixed window algorithm. When exceeded, the API returns HTTP 429 with a structured error response: - -```json -{ - "error": { - "message": "Rate limit exceeded. Try again in 60 seconds.", - "type": "rate_limit_exceeded", - "code": "too_many_requests", - "retry_after": 60 - } -} -``` - -Configure rate limiting through environment variables: +# Development (auto-reload) +poetry run uvicorn src.main:app --reload --port 8000 -```bash -RATE_LIMIT_ENABLED=true -RATE_LIMIT_CHAT_PER_MINUTE=10 -RATE_LIMIT_DEBUG_PER_MINUTE=2 -RATE_LIMIT_AUTH_PER_MINUTE=10 -RATE_LIMIT_HEALTH_PER_MINUTE=30 +# Production +poetry run python main.py ``` -## Running the Server - -1. Verify Claude Code is installed and working: - ```bash - claude --version - claude --print --model claude-haiku-4-5-20251001 "Hello" # Test with fastest model - ``` - -2. Start the server: - - **Development mode (recommended - auto-reloads on changes):** - ```bash - poetry run uvicorn src.main:app --reload --port 8000 - ``` - - **Production mode:** - ```bash - poetry run python main.py - ``` - - **Port Options for production mode:** - - Default: Uses port 8000 (or PORT from .env) - - If port is in use, automatically finds next available port - - Specify custom port: `poetry run python main.py 9000` - - Set in environment: `PORT=9000 poetry run python main.py` - ## Docker -Build and run the wrapper in a Docker container. - -### Build - ```bash +# Build docker build -t claude-wrapper:latest . -``` - -### Run -**Production:** -```bash +# Run docker run -d -p 8000:8000 \ -v ~/.claude:/root/.claude \ --name claude-wrapper \ claude-wrapper:latest -``` -**With custom workspace:** -```bash +# With custom workspace docker run -d -p 8000:8000 \ -v ~/.claude:/root/.claude \ -v /path/to/project:/workspace \ @@ -329,16 +141,7 @@ docker run -d -p 8000:8000 \ claude-wrapper:latest ``` -**Development (hot reload):** -```bash -docker run -d -p 8000:8000 \ - -v ~/.claude:/root/.claude \ - -v $(pwd):/app \ - claude-wrapper:latest \ - poetry run uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload -``` - -### Docker Compose +Docker Compose: ```yaml version: '3.8' @@ -355,419 +158,195 @@ services: restart: unless-stopped ``` -Run: `docker-compose up -d` | Stop: `docker-compose down` - -### Environment Variables - | Variable | Description | Default | |----------|-------------|---------| | `PORT` | Server port | `8000` | | `MAX_TIMEOUT` | Request timeout (seconds) | `300` | | `CLAUDE_CWD` | Working directory | temp dir | -| `CLAUDE_AUTH_METHOD` | Auth method: `cli`, `api_key`, `bedrock`, `vertex` | auto-detect | +| `CLAUDE_AUTH_METHOD` | `cli`, `api_key`, `bedrock`, `vertex` | auto-detect | | `ANTHROPIC_API_KEY` | Direct API key | - | -| `API_KEYS` | Comma-separated client API keys | - | - -### Management - -```bash -docker logs -f claude-wrapper # View logs -docker stop claude-wrapper # Stop -docker start claude-wrapper # Start -docker rm claude-wrapper # Remove -``` - -### Test - -```bash -curl http://localhost:8000/health -curl http://localhost:8000/v1/models -``` ## Usage Examples -### Using curl +### curl ```bash -# Basic chat completion (no auth) curl -X POST http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ - "model": "claude-sonnet-4-5-20250929", + "model": "claude-sonnet-4-6", "messages": [ {"role": "user", "content": "What is 2 + 2?"} ] }' - -# With API key protection (when enabled) -curl -X POST http://localhost:8000/v1/chat/completions \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer your-generated-api-key" \ - -d '{ - "model": "claude-sonnet-4-5-20250929", - "messages": [ - {"role": "user", "content": "Write a Python hello world script"} - ], - "stream": true - }' ``` -### Using OpenAI Python SDK +### OpenAI Python SDK ```python from openai import OpenAI -# Configure client (automatically detects auth requirements) client = OpenAI( base_url="http://localhost:8000/v1", - api_key="your-api-key-if-required" # Only needed if protection enabled + api_key="your-api-key-if-required" ) -# Alternative: Let examples auto-detect authentication -# The wrapper's example files automatically check server auth status - -# Basic chat completion +# Basic completion response = client.chat.completions.create( - model="claude-sonnet-4-5-20250929", + model="claude-sonnet-4-6", messages=[ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "What files are in the current directory?"} ] ) - print(response.choices[0].message.content) -# Output: Fast response without tool usage (default behaviour) -# Enable tools when you need them (e.g., to read files) +# With tools enabled response = client.chat.completions.create( - model="claude-sonnet-4-5-20250929", + model="claude-sonnet-4-6", messages=[ {"role": "user", "content": "What files are in the current directory?"} ], - extra_body={"enable_tools": True} # Enable tools for file access + extra_body={"enable_tools": True} ) -print(response.choices[0].message.content) -# Output: Claude will actually read your directory and list the files! - -# Check real costs and tokens -print(f"Cost: ${response.usage.total_tokens * 0.000003:.6f}") # Real cost tracking -print(f"Tokens: {response.usage.total_tokens} ({response.usage.prompt_tokens} + {response.usage.completion_tokens})") # Streaming stream = client.chat.completions.create( - model="claude-sonnet-4-5-20250929", - messages=[ - {"role": "user", "content": "Explain quantum computing"} - ], + model="claude-sonnet-4-6", + messages=[{"role": "user", "content": "Explain quantum computing"}], stream=True ) - for chunk in stream: if chunk.choices[0].delta.content: print(chunk.choices[0].delta.content, end="") ``` -## Supported Models +### Claude-specific headers -All Claude models through November 2025 are supported: +Pass Claude SDK options via custom HTTP headers: -### Claude 4.5 Family (Latest - Fall 2025) -- **`claude-opus-4-5-20250929`** 🎯 Most Capable - Latest Opus with enhanced reasoning and capabilities -- **`claude-sonnet-4-5-20250929`** ⭐ Recommended - Best coding model, superior reasoning and math -- **`claude-haiku-4-5-20251001`** ⚡ Fast & Cheap - Similar performance to Sonnet 4 at 1/3 cost +| Header | Values | Description | +|--------|--------|-------------| +| `X-Claude-Max-Turns` | integer | Max conversation turns | +| `X-Claude-Allowed-Tools` | comma-separated | Tools to allow | +| `X-Claude-Permission-Mode` | `default`, `acceptEdits`, `bypassPermissions`, `plan` | Permission mode | +| `X-Claude-Effort` | `low`, `medium`, `high`, `max` | Model effort level | +| `X-Claude-Thinking` | `adaptive`, `enabled`, `disabled` | Extended thinking mode | +| `X-Claude-Max-Thinking-Tokens` | integer | Thinking token budget | -### Claude 4.1 & 4.0 Family -- **`claude-opus-4-1-20250805`** - Upgraded Opus 4 with improved agentic tasks and reasoning -- `claude-opus-4-20250514` - Original Opus 4 with extended thinking mode -- `claude-sonnet-4-20250514` - Original Sonnet 4 with hybrid reasoning +## Supported Models -### Claude 3.x Family -- `claude-3-7-sonnet-20250219` - Hybrid model with rapid/thoughtful response modes -- `claude-3-5-sonnet-20241022` - Previous generation Sonnet -- `claude-3-5-haiku-20241022` - Previous generation fast model +All model IDs, context windows, and pricing sourced from the open-sourced Claude Code CLI. -**Note:** The model parameter is passed to Claude Code via the SDK's model selection. +### Claude 4.6 (Latest) +| Model | Context | Max Output | Input $/MTok | Output $/MTok | +|-------|---------|-----------|-------------|--------------| +| `claude-opus-4-6` | 200K | 128K | $5 | $25 | +| `claude-sonnet-4-6` (default) | 200K | 128K | $3 | $15 | -## Session Continuity 🆕 +### Claude 4.5 +| Model | Context | Max Output | Input $/MTok | Output $/MTok | +|-------|---------|-----------|-------------|--------------| +| `claude-opus-4-5-20251101` | 200K | 64K | $5 | $25 | +| `claude-sonnet-4-5-20250929` | 200K | 64K | $3 | $15 | +| `claude-haiku-4-5-20251001` | 200K | 64K | $1 | $5 | -The wrapper now supports **session continuity**, allowing you to maintain conversation context across multiple requests. This is a powerful feature that goes beyond the standard OpenAI API. +### Claude 4.1 / 4.0 +| Model | Context | Max Output | Input $/MTok | Output $/MTok | +|-------|---------|-----------|-------------|--------------| +| `claude-opus-4-1-20250805` | 200K | 64K | $15 | $75 | +| `claude-opus-4-20250514` | 200K | 64K | $15 | $75 | +| `claude-sonnet-4-20250514` | 200K | 64K | $3 | $15 | -### How It Works +### Claude 3.x +| Model | Context | Max Output | Input $/MTok | Output $/MTok | +|-------|---------|-----------|-------------|--------------| +| `claude-3-7-sonnet-20250219` | 200K | 64K | $3 | $15 | +| `claude-3-5-sonnet-20241022` | 200K | 8K | $3 | $15 | +| `claude-3-5-haiku-20241022` | 200K | 8K | $0.80 | $4 | -- **Stateless Mode** (default): Each request is independent, just like the standard OpenAI API -- **Session Mode**: Include a `session_id` to maintain conversation history across requests +## Session Continuity -### Using Sessions with OpenAI SDK +Maintain conversation context across requests by including a `session_id`: ```python -import openai - -client = openai.OpenAI( - base_url="http://localhost:8000/v1", - api_key="not-needed" -) - -# Start a conversation with session continuity +# Start a conversation response1 = client.chat.completions.create( - model="claude-sonnet-4-5-20250929", - messages=[ - {"role": "user", "content": "Hello! My name is Alice and I'm learning Python."} - ], - extra_body={"session_id": "my-learning-session"} + model="claude-sonnet-4-6", + messages=[{"role": "user", "content": "My name is Alice."}], + extra_body={"session_id": "my-session"} ) -# Continue the conversation - Claude remembers the context +# Continue it -- Claude remembers the context response2 = client.chat.completions.create( - model="claude-sonnet-4-5-20250929", - messages=[ - {"role": "user", "content": "What's my name and what am I learning?"} - ], - extra_body={"session_id": "my-learning-session"} # Same session ID + model="claude-sonnet-4-6", + messages=[{"role": "user", "content": "What's my name?"}], + extra_body={"session_id": "my-session"} ) -# Claude will remember: "Your name is Alice and you're learning Python." -``` - -### Using Sessions with curl - -```bash -# First message (add -H "Authorization: Bearer your-key" if auth enabled) -curl -X POST http://localhost:8000/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{ - "model": "claude-sonnet-4-5-20250929", - "messages": [{"role": "user", "content": "My favourite color is blue."}], - "session_id": "my-session" - }' - -# Follow-up message - context is maintained -curl -X POST http://localhost:8000/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{ - "model": "claude-sonnet-4-5-20250929", - "messages": [{"role": "user", "content": "What's my favourite color?"}], - "session_id": "my-session" - }' ``` -### Session Management - -The wrapper provides endpoints to manage active sessions: - -- `GET /v1/sessions` - List all active sessions -- `GET /v1/sessions/{session_id}` - Get session details -- `DELETE /v1/sessions/{session_id}` - Delete a session -- `GET /v1/sessions/stats` - Get session statistics - -```bash -# List active sessions -curl http://localhost:8000/v1/sessions - -# Get session details -curl http://localhost:8000/v1/sessions/my-session - -# Delete a session -curl -X DELETE http://localhost:8000/v1/sessions/my-session -``` - -### Session Features - -- **Automatic Expiration**: Sessions expire after 1 hour of inactivity -- **Streaming Support**: Session continuity works with both streaming and non-streaming requests -- **Memory Persistence**: Full conversation history is maintained within the session -- **Efficient Storage**: Only active sessions are kept in memory - -### Examples - -See `examples/session_continuity.py` for comprehensive Python examples and `examples/session_curl_example.sh` for curl examples. +Sessions expire after 1 hour of inactivity. Manage them via: +- `GET /v1/sessions` -- list active sessions +- `GET /v1/sessions/{id}` -- session details +- `DELETE /v1/sessions/{id}` -- delete session +- `GET /v1/sessions/stats` -- session statistics ## API Endpoints -### Core Endpoints -- `GET /` - Interactive landing page with API explorer -- `POST /v1/chat/completions` - OpenAI-compatible chat completions (supports `session_id`) -- `POST /v1/messages` - Anthropic-compatible messages endpoint -- `GET /v1/models` - List available models -- `GET /v1/auth/status` - Check authentication status and configuration -- `GET /version` - Get API version -- `GET /health` - Health check endpoint - -### Session Management Endpoints 🆕 -- `GET /v1/sessions` - List all active sessions -- `GET /v1/sessions/{session_id}` - Get detailed session information -- `DELETE /v1/sessions/{session_id}` - Delete a specific session -- `GET /v1/sessions/stats` - Get session manager statistics - -## Limitations & Roadmap - -### 🚫 **Current Limitations** -- **Images in messages** are converted to text placeholders -- **Function calling** not supported (tools work automatically based on prompts) -- **OpenAI parameters** not yet mapped: `temperature`, `top_p`, `max_tokens`, `logit_bias`, `presence_penalty`, `frequency_penalty` -- **Multiple responses** (`n > 1`) not supported - -### 🛣 **Planned Enhancements** -- [ ] **Tool configuration** - allowed/disallowed tools endpoints -- [ ] **OpenAI parameter mapping** - temperature, top_p, max_tokens support -- [ ] **Enhanced streaming** - better chunk handling -- [ ] **MCP integration** - Model Context Protocol server support - -### ✅ **Recent Improvements (v2.2.0)** -- **Interactive Landing Page**: API explorer with live endpoint testing -- **Anthropic Messages API**: Native `/v1/messages` endpoint -- **Explicit Auth Selection**: `CLAUDE_AUTH_METHOD` env var -- **Tool Execution Fix**: `enable_tools: true` now works correctly - -### ✅ **v2.0.0 - v2.1.0 Features** -- Claude Agent SDK v0.1.18 with bundled CLI -- Multi-provider auth (CLI, API key, Bedrock, Vertex AI) -- Session continuity and management -- Real-time cost and token tracking -- System prompt support - -## Troubleshooting - -1. **Claude CLI not found**: - ```bash - # Check Claude is in PATH - which claude - # Update CLAUDE_CLI_PATH in .env if needed - ``` - -2. **Authentication errors**: - ```bash - # Test authentication with fastest model - claude --print --model claude-haiku-4-5-20251001 "Hello" - # If this fails, re-authenticate if needed - ``` - -3. **Timeout errors**: - - Increase `MAX_TIMEOUT` in `.env` - - Note: Claude Code can take time for complex requests +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/` | GET | Landing page with API explorer | +| `/v1/chat/completions` | POST | OpenAI-compatible chat | +| `/v1/messages` | POST | Anthropic-compatible messages | +| `/v1/models` | GET | List models | +| `/v1/models/refresh` | POST | Refresh models from API | +| `/v1/models/status` | GET | Model service status | +| `/v1/auth/status` | GET | Auth status | +| `/v1/sessions` | GET | List sessions | +| `/v1/sessions/{id}` | GET/DELETE | Session details / delete | +| `/v1/sessions/stats` | GET | Session statistics | +| `/v1/cache/stats` | GET | Cache statistics | +| `/v1/cache/clear` | POST | Clear cache | +| `/version` | GET | API version | +| `/health` | GET | Health check | + +## Limitations + +- Images in messages are converted to text placeholders +- OpenAI-style function calling not supported (tools auto-execute based on prompts) +- `temperature`, `top_p`, `presence_penalty`, `frequency_penalty` are accepted but not passed to Claude SDK +- Multiple responses (`n > 1`) not supported ## Testing -### 🧪 **Quick Test Suite** -Test all endpoints with a simple script: ```bash -# Make sure server is running first -poetry run python test_endpoints.py -``` - -### 📝 **Basic Test Suite** -Run the comprehensive test suite: -```bash -# Make sure server is running first -poetry run python test_basic.py - -# With API key protection enabled, set TEST_API_KEY: -TEST_API_KEY=your-generated-key poetry run python test_basic.py -``` - -The test suite automatically detects whether API key protection is enabled and provides helpful guidance for providing the necessary authentication. - -### 🔍 **Authentication Test** -Check authentication status: -```bash -curl http://localhost:8000/v1/auth/status | python -m json.tool -``` - -### ⚙️ **Development Tools** -```bash -# Install development dependencies -poetry install --with dev - -# Format code -poetry run black . - -# Run full tests (when implemented) +# Run the full test suite poetry run pytest tests/ -``` -### ✅ **Expected Results** -All tests should show: -- **4/4 endpoint tests passing** -- **4/4 basic tests passing** -- **Authentication method detected** (claude_cli, anthropic, bedrock, or vertex) -- **Real cost tracking** (e.g., $0.001-0.005 per test call) -- **Accurate token counts** from SDK metadata +# Quick endpoint test (server must be running) +poetry run python test_endpoints.py +``` ## Terms Compliance -This wrapper is designed to be compliant with [Anthropic's Terms of Service](https://www.anthropic.com/legal). - -### Requirements for Users - -> **Important:** You must have your own valid Claude subscription or API access to use this wrapper. - -- **Claude Pro or Max subscription** - For CLI authentication (`claude auth login`) -- **Anthropic API key** - Available at [platform.claude.com](https://platform.claude.com) -- **AWS Bedrock or Google Vertex AI** - For enterprise cloud authentication - -This wrapper does not provide Claude access - it provides an OpenAI-compatible interface to Claude services you already have access to. - -### How This Wrapper Works - -- **Uses the official Claude Agent SDK** - The same SDK Anthropic provides for developers -- **Each user authenticates individually** - No credential sharing or pooling -- **Format translation only** - Converts OpenAI-format requests to Claude SDK calls -- **No reselling** - Users access Claude through their own subscriptions/API keys - -### Personal vs Commercial Use - -| Use Case | Recommended Authentication | Notes | -|----------|---------------------------|-------| -| Personal projects | CLI Auth (Pro/Max) or API Key | Acceptable at moderate scale | -| Business/Commercial | API Key, Bedrock, or Vertex AI | Use [platform.claude.com](https://platform.claude.com) | -| High-scale applications | Bedrock or Vertex AI | Enterprise authentication recommended | - -**Note on Consumer Plans:** Claude Pro and Max subscriptions are primarily designed for individual, interactive use. Using them through wrappers or automated implementations is acceptable for personal projects at moderate scale. For business use or applications that scale significantly, Anthropic's commercial API offerings at [platform.claude.com](https://platform.claude.com) are more appropriate. - -### Authentication Methods - -| Method | Terms | Compliance | -|--------|-------|------------| -| `ANTHROPIC_API_KEY` | Commercial Terms | Explicitly allowed for programmatic access | -| AWS Bedrock | Commercial Terms | Explicitly allowed for programmatic access | -| Google Vertex AI | Commercial Terms | Explicitly allowed for programmatic access | -| CLI Auth (Pro/Max) | Consumer Terms | Uses official SDK with official auth methods | - -### CLI Authentication Note - -Using CLI auth (`claude auth login`) with this wrapper is functionally equivalent to using Claude Code directly - both use the Claude Agent SDK with your personal subscription. Anthropic provides the SDK with CLI auth support, and this wrapper simply provides an alternative interface format. - -### What This Wrapper Does NOT Do - -- Does not share or pool credentials between users -- Does not include or expose API keys or credentials -- Does not resell API access -- Does not train competing AI models -- Does not scrape or harvest data -- Does not bypass authentication or rate limits - -### User Responsibilities - -By using this wrapper, you agree to: -- Comply with [Anthropic's Terms of Service](https://www.anthropic.com/legal/consumer-terms) -- Comply with [Anthropic's Usage Policy](https://www.anthropic.com/legal/aup) -- Use your own valid Claude subscription or API access -- Not share your credentials with others -- Use commercial API access for business applications - -### Disclaimer +This wrapper requires your own Claude subscription or API access. It translates request formats -- it does not provide Claude access itself. -This is an independent open-source project, not affiliated with or endorsed by Anthropic. Users are responsible for ensuring their own usage complies with Anthropic's terms. Anthropic reserves the right to modify their Terms of Service at any time. +- Uses the official Claude Agent SDK +- Each user authenticates individually (no credential sharing) +- No reselling, no data harvesting -When in doubt, use `ANTHROPIC_API_KEY` authentication which is explicitly permitted for programmatic access under the Commercial Terms. +| Use Case | Recommended Auth | +|----------|-----------------| +| Personal projects | CLI Auth or API Key | +| Business / commercial | API Key, Bedrock, or Vertex AI | +| High-scale | Bedrock or Vertex AI | -For Anthropic's official terms, see: -- [Usage Policy](https://www.anthropic.com/legal/aup) -- [Consumer Terms](https://www.anthropic.com/legal/consumer-terms) -- [Commercial Terms](https://www.anthropic.com/legal/commercial-terms) +See [Anthropic's Terms of Service](https://www.anthropic.com/legal) for details. ## Licence -MIT Licence +MIT ## Contributing -Contributions are welcome! Please open an issue or submit a pull request. +Contributions welcome. Open an issue or submit a pull request. diff --git a/docs/MIGRATION_STATUS.md b/docs/MIGRATION_STATUS.md index bdb586a..efe50f7 100644 --- a/docs/MIGRATION_STATUS.md +++ b/docs/MIGRATION_STATUS.md @@ -1,132 +1,36 @@ # Claude Agent SDK Migration Status -**Date:** 2025-11-02 -**Status:** ✅ **MIGRATION COMPLETE** (Testing limited by environment) - -## ✅ Completed - -1. **Dependency Updates** - - ✅ Updated `pyproject.toml` from `claude-code-sdk ^0.0.14` to `claude-agent-sdk ^0.1.6` - - ✅ Updated version to 2.0.0 - - ✅ Successfully ran `poetry lock` and `poetry install` - - ✅ Verified claude-agent-sdk 0.1.6 installation - -2. **Code Updates** - - ✅ Updated imports: `claude_code_sdk` → `claude_agent_sdk` - - ✅ Renamed `ClaudeCodeOptions` → `ClaudeAgentOptions` throughout codebase - - ✅ Updated all SDK references in log messages and comments - - ✅ Fixed f-string syntax error in `main.py` line 149 - - ✅ Updated compatibility endpoint response field names - -3. **Files Modified** - - ✅ `pyproject.toml` - Dependencies and version - - ✅ `claude_cli.py` - Imports, options class, logging - - ✅ `main.py` - SDK references, syntax fix - -4. **Basic Testing** - - ✅ SDK imports successfully (`from claude_agent_sdk import query, ClaudeAgentOptions, Message`) - - ✅ Server starts without import errors - - ✅ Health endpoint works (`/health`) - - ✅ Models endpoint works (`/v1/models`) - - ✅ Auth status endpoint works (`/v1/auth/status`) - -## ⚠️ Environment-Specific Issue (Not a Migration Problem) - -### Issue: SDK Query Hangs During Testing - -**Root Cause Identified:** -The testing environment is **INSIDE Claude Code's own container** (`CLAUDE_CODE_REMOTE=true`), which creates a recursive situation when trying to use the Claude Code SDK from within Claude Code itself. - -**Environment Details:** -``` -CLAUDE_CODE_VERSION=2.0.25 -CLAUDE_CODE_REMOTE=true -CLAUDE_CODE_ENTRYPOINT=remote -CLAUDE_CODE_CONTAINER_ID=container_011CUjNxa7A9jwwXtRTAocKf... -``` +> **Historical document.** This migration was completed in November 2025. The wrapper now runs on Claude Agent SDK v0.1.18. Kept for reference only. -**Why This Happens:** -- The wrapper is designed to run in a **normal environment** (user's machine, VPS, Docker container) -- It then calls Claude Code CLI as an external tool -- Testing from within Claude Code itself creates recursion/nesting issues -- This is NOT a problem with the migration code itself - -**Expected Behavior in Production:** -The wrapper is designed to be deployed to: -- ✅ User's local machine (macOS, Linux, Windows) -- ✅ Docker container (standalone) -- ✅ VPS/cloud server (AWS, GCP, DigitalOcean, etc.) -- ✅ Any standard Python environment with Claude Code CLI installed - -**Current Workaround for Testing:** -- Disabled SDK verification during startup to allow server to start -- Basic endpoints (health, models, auth) work fine -- Chat completions cannot be fully tested in this environment - -## ✅ Migration Assessment +**Date:** 2025-11-02 +**Status:** Complete -**The migration is COMPLETE and CORRECT.** +## What was migrated -All code changes have been successfully implemented: -- Dependencies updated -- Imports changed -- Class names renamed -- Syntax errors fixed -- References updated +1. **Dependencies**: `claude-code-sdk ^0.0.14` replaced with `claude-agent-sdk ^0.1.18` +2. **Imports**: `claude_code_sdk` to `claude_agent_sdk`, `ClaudeCodeOptions` to `ClaudeAgentOptions` +3. **System prompts**: Switched to structured format (`{"type": "preset", "preset": "claude_code"}`) -**The hanging issue is environmental, not a code problem.** +## Files changed -When deployed to a proper environment (not inside Claude Code), the wrapper will work as expected with the new Claude Agent SDK v0.1.6. +- `pyproject.toml` -- dependency and version +- `claude_cli.py` -- imports, options class, logging +- `main.py` -- SDK references -## 📋 Deployment Checklist +## Testing notes -For users deploying the migrated wrapper: +The migration was tested inside Claude Code's own container (`CLAUDE_CODE_REMOTE=true`), which caused SDK query hangs due to recursion. This is an environment issue, not a code problem. The wrapper works correctly when deployed to a normal environment. -### Prerequisites -1. ✅ Python 3.10+ -2. ✅ Node.js installed -3. ✅ Claude Code 2.0.0+ installed: `npm install -g @anthropic-ai/claude-code` -4. ✅ Authentication configured (API key, Bedrock, Vertex, or CLI auth) +## Deployment -### Installation ```bash git clone https://github.com/RichardAtCT/claude-code-openai-wrapper cd claude-code-openai-wrapper -git checkout claude/research-api-updates-011CUjNxYatBANZZq6bssaxN poetry install poetry run uvicorn src.main:app --host 0.0.0.0 --port 8000 ``` -### Verification -```bash -# Test endpoints -curl http://localhost:8000/health -curl http://localhost:8000/v1/models - -# Test chat completion -curl -X POST http://localhost:8000/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{ - "model": "claude-3-5-haiku-20241022", - "messages": [{"role": "user", "content": "Hello!"}] - }' -``` - -## 📚 References - -- [Claude Agent SDK PyPI](https://pypi.org/project/claude-agent-sdk/) -- [Migration Guide](https://docs.claude.com/en/docs/claude-code/sdk/migration-guide) -- [UPGRADE_PLAN.md](./UPGRADE_PLAN.md) - Original migration plan -- [GitHub Issue #289](https://github.com/anthropics/claude-agent-sdk-python/issues/289) - System prompt defaults - -## 💡 Next Steps - -1. **For Maintainer:** Update README.md to reflect v2.0.0 and new SDK -2. **For Users:** Deploy to proper environment and test end-to-end -3. **Future Work:** Consider OpenAI API 2025 enhancements (Phase 2 of upgrade plan) - ---- +## References -**Last Updated:** 2025-11-02 17:52:00 UTC -**Updated By:** Claude (Migration Assistant) -**Status:** ✅ Migration Complete (Environmental testing limitations noted) +- [Claude Agent SDK on PyPI](https://pypi.org/project/claude-agent-sdk/) +- [UPGRADE_PLAN.md](./UPGRADE_PLAN.md) -- original migration plan (historical) diff --git a/docs/UPGRADE_PLAN.md b/docs/UPGRADE_PLAN.md index a2348ea..7b1b1b3 100644 --- a/docs/UPGRADE_PLAN.md +++ b/docs/UPGRADE_PLAN.md @@ -1,807 +1,36 @@ -# Claude Code OpenAI Wrapper - Upgrade Plan +# Claude Code OpenAI Wrapper -- Upgrade Plan -**Date:** 2025-11-02 -**Current Version:** claude-code-sdk 0.0.14 -**Target Version:** claude-agent-sdk 0.1.6 +> **Historical document.** This plan was written 2025-11-02 for the SDK migration from `claude-code-sdk 0.0.14` to `claude-agent-sdk 0.1.6`. The migration is complete and the wrapper now runs on v0.1.18. Kept for reference. -## Executive Summary +## What was planned -This document outlines a comprehensive plan to upgrade the Claude Code OpenAI Wrapper to use the latest Claude Agent SDK (v0.1.6) and implement the latest OpenAI API standards as of 2025. The upgrade involves a critical SDK migration and implementation of new OpenAI API features. +### Phase 1: SDK Migration (completed) +- Replace `claude-code-sdk` with `claude-agent-sdk` +- Rename `ClaudeCodeOptions` to `ClaudeAgentOptions` +- Switch to structured system prompt format +- Handle settings sources change (SDK no longer auto-reads filesystem settings) ---- +### Phase 2: OpenAI API parameter support (partially completed) +- `max_tokens` / `max_completion_tokens` -- now validated against per-model limits (v2.5.0) +- `stream_options.include_usage` -- implemented +- `temperature`, `top_p`, `stop` -- accepted but not passed through to Claude SDK +- `n > 1`, function calling -- not supported -## 1. Claude Agent SDK Migration +### Key breaking changes that were handled +1. **System prompt**: No longer defaults to Claude Code preset; explicitly set via `{"type": "preset", "preset": "claude_code"}` +2. **Settings sources**: Must be explicitly enabled if needed +3. **Package name**: `claude-code-sdk` renamed to `claude-agent-sdk` -### 1.1 Current State Analysis +## What wasn't implemented -**Current Implementation:** -- **SDK:** `claude-code-sdk` version 0.0.14 (deprecated) -- **Import:** `from claude_code_sdk import query, ClaudeCodeOptions, Message` -- **Main File:** `claude_cli.py` (lines 11, 114-131) -- **Usage Pattern:** Direct SDK `query()` function with `ClaudeCodeOptions` +- OpenAI-style function calling / tool use translation +- In-process MCP servers via `create_sdk_mcp_server()` +- SDK hooks for pre/post tool validation +- `ClaudeSDKClient` for bidirectional conversations -**Issues with Current Version:** -- The `claude-code-sdk` package is deprecated (last version 0.0.25) -- Missing latest features and improvements -- No longer maintained or supported -- Security and performance improvements not available +These remain potential future work. -### 1.2 Target State +## References -**Target SDK:** `claude-agent-sdk` version 0.1.6 -- **Released:** October 31, 2025 -- **Python Requirements:** Python >=3.10 -- **Additional Requirements:** - - Node.js - - Claude Code 2.0.0+ (`npm install -g @anthropic-ai/claude-code`) - -### 1.3 Breaking Changes & Migration Steps - -#### 1.3.1 Package Installation Changes - -**Before:** -```bash -pip install claude-code-sdk -``` - -**After:** -```bash -pip uninstall claude-code-sdk -pip install claude-agent-sdk -``` - -**pyproject.toml Update:** -```toml -# Before: -claude-code-sdk = "^0.0.14" - -# After: -claude-agent-sdk = "^0.1.6" -``` - -#### 1.3.2 Import Statement Changes - -**Before (claude_cli.py:11):** -```python -from claude_code_sdk import query, ClaudeCodeOptions, Message -``` - -**After:** -```python -from claude_agent_sdk import query, ClaudeAgentOptions, Message -``` - -#### 1.3.3 Options Class Rename - -**Breaking Change:** `ClaudeCodeOptions` → `ClaudeAgentOptions` - -**Files to Update:** -- `claude_cli.py` (lines 11, 63, 114) - -**Before:** -```python -options = ClaudeCodeOptions( - max_turns=max_turns, - cwd=self.cwd -) -``` - -**After:** -```python -options = ClaudeAgentOptions( - max_turns=max_turns, - cwd=self.cwd -) -``` - -#### 1.3.4 System Prompt Configuration Changes - -**Critical Breaking Change:** System prompt no longer defaults to Claude Code preset. - -**Current Implementation (claude_cli.py:124-125):** -```python -if system_prompt: - options.system_prompt = system_prompt -``` - -**New Implementation:** -```python -if system_prompt: - # New structured system prompt format - options.system_prompt = { - "type": "text", - "text": system_prompt - } -else: - # Restore Claude Code default behavior (RECOMMENDED) - options.system_prompt = { - "type": "preset", - "preset": "claude_code" - } -``` - -**Alternative Approaches:** -1. **Keep current behavior:** Set `type: "text"` with custom system prompts -2. **Use Claude Code preset:** Set `type: "preset", preset: "claude_code"` -3. **No system prompt:** Omit the field entirely for vanilla Claude behavior - -#### 1.3.5 Settings Sources Configuration - -**Breaking Change:** SDK no longer reads filesystem settings by default. - -**Current Behavior:** Automatically loads from: -- `CLAUDE.md` -- `settings.json` -- Slash commands -- User/project settings - -**New Behavior:** Must explicitly enable: -```python -options = ClaudeAgentOptions( - max_turns=max_turns, - cwd=self.cwd, - setting_sources=['user', 'project', 'local'] # Add if needed -) -``` - -**Recommendation:** Only add if the wrapper needs to load filesystem settings. - -#### 1.3.6 New Features Available - -The Claude Agent SDK provides several new capabilities: - -**1. In-Process MCP Servers (Custom Tools)** -```python -from claude_agent_sdk import tool, create_sdk_mcp_server - -@tool("custom_tool", "Description", {"arg": str}) -async def custom_tool(args): - return {"content": [{"type": "text", "text": "Result"}]} - -server = create_sdk_mcp_server( - name="wrapper-tools", - version="1.0.0", - tools=[custom_tool] -) -``` - -**Benefits:** -- No subprocess overhead -- Better performance than external MCP servers -- Easier debugging -- Simplified deployment - -**2. Hooks for Deterministic Processing** -```python -async def validate_tool(input_data, tool_use_id, context): - # Validate before execution - pass - -options = ClaudeAgentOptions( - hooks={ - "PreToolUse": [ - HookMatcher(matcher="Bash", hooks=[validate_tool]) - ] - } -) -``` - -**3. ClaudeSDKClient for Bidirectional Conversations** -```python -from claude_agent_sdk import ClaudeSDKClient - -async with ClaudeSDKClient(options=options) as client: - await client.query("Your prompt") - async for msg in client.receive_response(): - print(msg) -``` - -### 1.4 Migration Implementation Plan - -#### Phase 1: Dependency Update -- [ ] Update `pyproject.toml` with `claude-agent-sdk = "^0.1.6"` -- [ ] Remove `claude-code-sdk` from dependencies -- [ ] Run `poetry lock` and `poetry install` -- [ ] Verify installation: `poetry show claude-agent-sdk` - -#### Phase 2: Code Updates -- [ ] Update imports in `claude_cli.py` -- [ ] Rename `ClaudeCodeOptions` to `ClaudeAgentOptions` -- [ ] Update system prompt handling with new structured format -- [ ] Add Claude Code preset as default system prompt -- [ ] Review and update authentication flow (if needed) - -#### Phase 3: Testing -- [ ] Update verification tests in `verify_cli()` method -- [ ] Test all existing functionality: - - Basic completions - - Streaming responses - - Session continuity - - Tool usage (enable/disable) - - Authentication methods -- [ ] Run existing test suite: `test_endpoints.py`, `test_basic.py` -- [ ] Test with different authentication methods -- [ ] Verify Docker deployment still works - -#### Phase 4: Documentation Updates -- [ ] Update README.md with new SDK version -- [ ] Update installation instructions -- [ ] Document breaking changes for users -- [ ] Update Docker image with new dependencies -- [ ] Update example files if needed - ---- - -## 2. OpenAI API Standards Update (2025) - -### 2.1 Current OpenAI API Compliance Status - -**Currently Supported:** -- ✅ Chat completions endpoint (`/v1/chat/completions`) -- ✅ Basic streaming with `stream: true` -- ✅ Message roles (system, user, assistant) -- ✅ Model selection -- ✅ Session management (custom extension) - -**Currently Not Supported:** -- ❌ `temperature` parameter (0-2) -- ❌ `max_tokens` / `max_completion_tokens` parameter -- ❌ `top_p` parameter (nucleus sampling) -- ❌ `frequency_penalty` parameter -- ❌ `presence_penalty` parameter -- ❌ `logit_bias` parameter -- ❌ `n` parameter (multiple completions) -- ❌ `stop` sequences -- ❌ `stream_options` for usage data in streaming -- ❌ Image content in messages (currently converted to placeholders) -- ❌ Function calling / tools (OpenAI format) - -### 2.2 New OpenAI API Features (2025) - -#### 2.2.1 Max Tokens Evolution - -**Breaking Change:** `max_tokens` deprecated in favor of `max_completion_tokens` for certain models. - -**Current Parameter:** `max_tokens` -**New Parameter:** `max_completion_tokens` (for o1-series models) - -**Reason:** Support for "hidden tokens" in reasoning models (o1-preview, o1-mini) - -**Implementation Strategy:** -```python -# In models.py ChatCompletionRequest -max_tokens: Optional[int] = None # Legacy support -max_completion_tokens: Optional[int] = None # New standard - -# Map to Claude options -def to_claude_options(self): - options = {} - # Prefer max_completion_tokens if available - max_tok = self.max_completion_tokens or self.max_tokens - if max_tok: - options['max_thinking_tokens'] = max_tok # Map to Claude - return options -``` - -#### 2.2.2 Stream Options Enhancement - -**New Feature:** `stream_options` parameter for usage data in streaming responses. - -**Current Implementation:** No usage data in streaming -**New Implementation:** -```python -# Request: -{ - "stream": true, - "stream_options": { - "include_usage": true - } -} - -# Response: Additional final chunk with usage data -{ - "id": "chatcmpl-...", - "usage": { - "prompt_tokens": 100, - "completion_tokens": 50, - "total_tokens": 150 - } -} -``` - -**Files to Update:** -- `models.py`: Add `stream_options` field to `ChatCompletionRequest` -- `main.py`: Update `generate_streaming_response()` to emit usage chunk - -#### 2.2.3 GPT-5 New Parameters (Optional) - -If targeting cutting-edge compatibility: - -**1. Verbosity Parameter:** -```python -verbosity: Optional[Literal["low", "medium", "high"]] = None -# Controls response length/detail -``` - -**2. Reasoning Effort Parameter:** -```python -reasoning_effort: Optional[Literal["minimal", "low", "medium", "high"]] = None -# For reasoning models - control depth of reasoning -``` - -**Note:** These are GPT-5 specific. Implementation is optional for Claude wrapper. - -### 2.3 Priority Parameter Implementation - -Based on user demand and compatibility, prioritize: - -#### Priority 1 (High Impact): -1. **`temperature`** - Most commonly used parameter -2. **`max_tokens` / `max_completion_tokens`** - Essential for output control -3. **`stream_options.include_usage`** - Better streaming experience - -#### Priority 2 (Medium Impact): -4. **`top_p`** - Alternative to temperature -5. **`stop`** - Stop sequences for generation control -6. **`presence_penalty` / `frequency_penalty`** - Fine-tuning repetition - -#### Priority 3 (Low Impact): -7. **`n`** - Multiple completions (complex to implement with Claude) -8. **`logit_bias`** - Advanced use case -9. **GPT-5 specific parameters** - Future-proofing - -### 2.4 Parameter Mapping Strategy - -**Challenge:** Map OpenAI parameters to Claude SDK parameters. - -**Temperature Mapping:** -```python -# OpenAI: 0-2 (default 1) -# Claude: No direct equivalent in SDK - -# Options: -# 1. Include in system prompt -# 2. Use custom headers if SDK supports -# 3. Document as unsupported with warning -``` - -**Max Tokens Mapping:** -```python -# OpenAI: max_tokens / max_completion_tokens -# Claude: max_thinking_tokens (for extended thinking) - -# Map in to_claude_options(): -if self.max_completion_tokens or self.max_tokens: - options['max_thinking_tokens'] = self.max_completion_tokens or self.max_tokens -``` - -**Top-P Mapping:** -```python -# Similar to temperature - no direct Claude SDK equivalent -# Could combine with temperature in system prompt instruction -``` - -### 2.5 OpenAI API Implementation Plan - -#### Phase 1: Core Parameters -- [ ] Add `max_completion_tokens` to request model -- [ ] Add backward compatibility for `max_tokens` -- [ ] Implement parameter mapping to Claude options -- [ ] Add validation for parameter ranges - -#### Phase 2: Streaming Enhancements -- [ ] Add `stream_options` to request model -- [ ] Implement usage tracking in streaming responses -- [ ] Emit final usage chunk when `include_usage: true` - -#### Phase 3: Advanced Parameters -- [ ] Add `temperature` (document limitations) -- [ ] Add `top_p` (document limitations) -- [ ] Add `stop` sequences -- [ ] Add `presence_penalty` / `frequency_penalty` -- [ ] Document which parameters are best-effort vs full support - -#### Phase 4: Testing & Documentation -- [ ] Test parameter validation -- [ ] Test parameter mapping -- [ ] Create compatibility matrix in README -- [ ] Update API documentation -- [ ] Add examples for new parameters - ---- - -## 3. Implementation Priorities & Timeline - -### 3.1 Recommended Approach - -**Option A: Sequential Migration** (Lower Risk) -1. Complete Claude Agent SDK migration first -2. Test thoroughly -3. Then implement OpenAI API updates - -**Option B: Parallel Development** (Faster but Higher Risk) -1. Create feature branches for each workstream -2. Develop simultaneously -3. Integrate and test together - -**Recommendation:** Option A for stability, Option B if timeline is critical. - -### 3.2 Estimated Timeline - -**Phase 1: Claude Agent SDK Migration** -- Dependency updates: 1-2 hours -- Code updates: 2-4 hours -- Testing: 2-3 hours -- **Total: 1 day** - -**Phase 2: OpenAI API Core Parameters** -- Model updates: 2-3 hours -- Implementation: 3-4 hours -- Testing: 2-3 hours -- **Total: 1 day** - -**Phase 3: Streaming & Advanced Features** -- Implementation: 4-6 hours -- Testing: 2-3 hours -- **Total: 1 day** - -**Phase 4: Documentation & Polish** -- Documentation: 3-4 hours -- Final testing: 2-3 hours -- **Total: 0.5 day** - -**Total Estimated Time:** 3.5-4 days - -### 3.3 Risk Assessment - -**High Risk Items:** -1. ⚠️ System prompt migration (breaking change) -2. ⚠️ Behavior changes from SDK defaults -3. ⚠️ Authentication flow changes - -**Medium Risk Items:** -1. ⚠️ Parameter mapping accuracy -2. ⚠️ Streaming usage data implementation -3. ⚠️ Backward compatibility - -**Low Risk Items:** -1. Dependency updates -2. Import statement changes -3. Documentation updates - -### 3.4 Rollback Strategy - -**If Migration Fails:** -1. Revert `pyproject.toml` changes -2. Run `poetry lock && poetry install` -3. Restore original code from git - -**Recommended:** -- Create migration branch: `feature/sdk-migration` -- Test thoroughly before merging to main -- Tag current version before migration: `git tag v1.0.0-pre-migration` - ---- - -## 4. Compatibility Matrix (Post-Upgrade) - -### 4.1 Claude SDK Features - -| Feature | Current (0.0.14) | Target (0.1.6) | Status | -|---------|-----------------|----------------|--------| -| Basic completions | ✅ | ✅ | Maintained | -| Streaming | ✅ | ✅ | Maintained | -| System prompts | ✅ | ✅ | Breaking change | -| Tool control | ✅ | ✅ | Maintained | -| Session continuity | ✅ | ✅ | Maintained | -| In-process MCP | ❌ | ✅ | **New** | -| Hooks | ❌ | ✅ | **New** | -| Settings sources | Auto | Manual | Breaking change | - -### 4.2 OpenAI API Compliance - -| Feature | Pre-Upgrade | Post-Upgrade | Notes | -|---------|------------|--------------|-------| -| Chat completions | ✅ | ✅ | Core feature | -| Streaming | ✅ | ✅ | Enhanced with usage | -| `model` | ✅ | ✅ | Maintained | -| `messages` | ✅ | ✅ | Maintained | -| `temperature` | ❌ | ⚠️ | Best-effort | -| `max_tokens` | ❌ | ✅ | **New** | -| `max_completion_tokens` | ❌ | ✅ | **New** | -| `stream_options` | ❌ | ✅ | **New** | -| `top_p` | ❌ | ⚠️ | Best-effort | -| `stop` | ❌ | 🔄 | Planned | -| `n` | ❌ | ❌ | Not supported | -| Function calling | ❌ | ❌ | Not supported | - -**Legend:** -- ✅ Fully supported -- ⚠️ Partial/best-effort support -- 🔄 Planned for implementation -- ❌ Not supported - ---- - -## 5. Testing Strategy - -### 5.1 Test Coverage Requirements - -**Unit Tests:** -- [ ] SDK initialization with new `ClaudeAgentOptions` -- [ ] System prompt configuration variations -- [ ] Parameter validation for new OpenAI params -- [ ] Parameter mapping to Claude options - -**Integration Tests:** -- [ ] End-to-end completion request -- [ ] Streaming with usage data -- [ ] Session continuity across SDK version -- [ ] Authentication methods (API key, Bedrock, Vertex) - -**Regression Tests:** -- [ ] All existing `test_endpoints.py` tests pass -- [ ] All existing `test_basic.py` tests pass -- [ ] Session tests still functional -- [ ] Docker deployment works - -### 5.2 Test Files to Update - -1. **`test_endpoints.py`** - - Update expected behaviors - - Add tests for new parameters - -2. **`test_basic.py`** - - Verify SDK migration doesn't break basics - - Add streaming usage tests - -3. **`test_session_continuity.py`** - - Ensure sessions work with new SDK - - Test session persistence - -4. **New Test Files Needed:** - - `test_parameter_mapping.py` - Test OpenAI → Claude parameter mapping - - `test_sdk_migration.py` - Verify SDK upgrade behaviors - -### 5.3 Manual Testing Checklist - -- [ ] Basic chat completion works -- [ ] Streaming works with usage data -- [ ] Temperature parameter accepted (even if best-effort) -- [ ] Max tokens limiting works -- [ ] Session continuity maintained -- [ ] All authentication methods work -- [ ] Docker container builds and runs -- [ ] Example files work (`examples/openai_sdk.py`, etc.) - ---- - -## 6. Documentation Updates Required - -### 6.1 README.md Updates - -**Sections to Update:** -1. **Status section** - Update SDK version to 0.1.6 -2. **Features section** - Add new OpenAI parameter support -3. **Prerequisites** - Update Claude Code version requirement (2.0.0+) -4. **Installation** - Update dependency instructions -5. **Limitations & Roadmap** - Update with implemented features -6. **Supported Models** - Verify model list is current - -**New Sections to Add:** -- **Parameter Support Matrix** - Document OpenAI parameter compatibility -- **Migration Guide** - For users upgrading from older versions - -### 6.2 Code Documentation - -- [ ] Update docstrings in `claude_cli.py` -- [ ] Update comments explaining new SDK behavior -- [ ] Document system prompt configuration options -- [ ] Add examples for new parameters - -### 6.3 Example Files - -Files to review/update: -- `examples/openai_sdk.py` - Add parameter examples -- `examples/streaming.py` - Add stream_options example -- `examples/session_continuity.py` - Verify compatibility - ---- - -## 7. Rollout Plan - -### 7.1 Pre-Release Steps - -1. **Create feature branch:** `feature/upgrade-sdk-and-api` -2. **Tag current version:** `git tag v1.0.0-stable` -3. **Update dependencies** in branch -4. **Implement changes** following this plan -5. **Test thoroughly** with all test suites -6. **Update documentation** completely -7. **Test Docker build** and deployment - -### 7.2 Release Steps - -1. **Merge to main** after all tests pass -2. **Tag new version:** `git tag v2.0.0` (major version due to breaking changes) -3. **Update GitHub release notes** with: - - Breaking changes - - New features - - Migration instructions -4. **Update Docker Hub** with new image -5. **Notify users** via GitHub discussions/issues - -### 7.3 Post-Release Monitoring - -- Monitor GitHub issues for migration problems -- Be ready to provide support for breaking changes -- Consider creating a `v1.x` maintenance branch for critical fixes - ---- - -## 8. Breaking Changes for End Users - -### 8.1 System Prompt Behavior - -**Breaking Change:** Default system prompt behavior changes. - -**Impact:** Users relying on Claude Code default system prompt may see different behavior. - -**Migration:** -- No action needed if using custom system prompts -- Default now restored via `preset: "claude_code"` in SDK options - -### 8.2 Settings Files - -**Breaking Change:** Settings files no longer auto-loaded. - -**Impact:** Users with `CLAUDE.md`, custom settings.json may see different behavior. - -**Migration:** -- Explicitly enable via `setting_sources` if needed -- Most users won't be affected (wrapper doesn't rely on these) - -### 8.3 Dependency Requirements - -**Change:** New package name and version requirements. - -**Impact:** Users building from source need to update dependencies. - -**Migration:** -```bash -poetry lock --no-update -poetry install -# Or for Docker: -docker build --no-cache -t claude-wrapper:v2 . -``` - ---- - -## 9. Success Criteria - -The upgrade is considered successful when: - -✅ **Functional Requirements:** -- [ ] All existing tests pass with new SDK -- [ ] Streaming responses work correctly -- [ ] Session continuity maintained -- [ ] Authentication methods all functional -- [ ] Docker deployment successful -- [ ] At least 3 new OpenAI parameters implemented (`max_tokens`, `temperature`, `stream_options`) - -✅ **Quality Requirements:** -- [ ] No regressions in existing functionality -- [ ] Response times similar or better than before -- [ ] Error handling maintains quality -- [ ] Documentation complete and accurate - -✅ **User Experience:** -- [ ] Clear migration guide available -- [ ] Breaking changes well documented -- [ ] Examples updated and working -- [ ] GitHub issues addressed proactively - ---- - -## 10. Additional Recommendations - -### 10.1 Consider Future Enhancements - -**After migration is stable:** -1. **Implement In-Process MCP Tools** - Leverage new SDK capability for custom tools -2. **Add Hooks for Validation** - Use SDK hooks for tool usage validation -3. **Explore ClaudeSDKClient** - For more interactive conversation patterns -4. **Function Calling Translation** - Map OpenAI function calls to Claude tools - -### 10.2 Monitoring & Observability - -Consider adding: -- **Metrics collection** - Track SDK performance, error rates -- **Usage analytics** - Understand which parameters are most used -- **Error reporting** - Better error tracking for debugging - -### 10.3 Community Engagement - -- Share migration experience in GitHub discussions -- Contribute back to Claude Agent SDK if bugs found -- Update examples and share best practices - ---- - -## Appendix A: Quick Reference - -### Key Code Changes - -**Import Change:** -```python -# Before -from claude_code_sdk import query, ClaudeCodeOptions, Message - -# After -from claude_agent_sdk import query, ClaudeAgentOptions, Message -``` - -**Options Change:** -```python -# Before -options = ClaudeCodeOptions(max_turns=1, cwd="/path") - -# After -options = ClaudeAgentOptions( - max_turns=1, - cwd="/path", - system_prompt={"type": "preset", "preset": "claude_code"} -) -``` - -**Dependency Change:** -```toml -# Before -claude-code-sdk = "^0.0.14" - -# After -claude-agent-sdk = "^0.1.6" -``` - -### Key Commands - -```bash -# Update dependencies -poetry remove claude-code-sdk -poetry add claude-agent-sdk@^0.1.6 -poetry lock -poetry install - -# Test changes -poetry run python test_endpoints.py -poetry run python test_basic.py - -# Build Docker -docker build -t claude-wrapper:v2 . - -# Tag for release -git tag v2.0.0 -git push origin v2.0.0 -``` - ---- - -## Appendix B: Reference Links - -### Official Documentation -- [Claude Agent SDK PyPI](https://pypi.org/project/claude-agent-sdk/) -- [Claude Agent SDK GitHub](https://github.com/anthropics/claude-agent-sdk-python) -- [Migration Guide](https://docs.claude.com/en/docs/claude-code/sdk/migration-guide) -- [OpenAI API Reference](https://platform.openai.com/docs/api-reference) - -### Related Issues -- [System prompt defaults issue #289](https://github.com/anthropics/claude-agent-sdk-python/issues/289) - -### Community Resources -- [Claude Agent SDK Migration Guide Blog](https://kane.mx/posts/2025/claude-agent-sdk-update/) - ---- - -**Document Version:** 1.0 -**Last Updated:** 2025-11-02 -**Next Review:** After Phase 1 completion +- [Claude Agent SDK on PyPI](https://pypi.org/project/claude-agent-sdk/) +- [MIGRATION_STATUS.md](./MIGRATION_STATUS.md) -- migration completion report diff --git a/pyproject.toml b/pyproject.toml index dcc6fe5..a1f8f00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "claude-code-openai-wrapper" -version = "2.3.0" +version = "2.5.0" description = "OpenAI API-compatible wrapper for Claude Code" authors = ["Richard Atkinson "] readme = "README.md" diff --git a/src/__init__.py b/src/__init__.py index b07244b..46d5f9e 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ """Claude Code OpenAI Wrapper - A FastAPI-based OpenAI-compatible API for Claude Code.""" -__version__ = "2.4.2" +__version__ = "2.5.0" diff --git a/src/claude_cli.py b/src/claude_cli.py index d87057e..0c087d2 100644 --- a/src/claude_cli.py +++ b/src/claude_cli.py @@ -8,6 +8,8 @@ from claude_agent_sdk import query, ClaudeAgentOptions +from src.retry import RetryState, retry_delay + logger = logging.getLogger(__name__) @@ -104,6 +106,8 @@ async def run_completion( session_id: Optional[str] = None, continue_session: bool = False, permission_mode: Optional[str] = None, + effort: Optional[str] = None, + thinking: Optional[str] = None, ) -> AsyncGenerator[Dict[str, Any], None]: """Run Claude Agent using the Python SDK and yield response chunks.""" @@ -141,37 +145,66 @@ async def run_completion( if permission_mode: options.permission_mode = permission_mode + # Set effort level and thinking mode if specified + if effort: + options.effort = effort + if thinking: + options.thinking = thinking + # Handle session continuity if continue_session: options.continue_session = True elif session_id: options.resume = session_id - # Run the query and yield messages - async for message in query(prompt=prompt, options=options): - # Debug logging - logger.debug(f"Raw SDK message type: {type(message)}") - logger.debug(f"Raw SDK message: {message}") - - # Convert message object to dict if needed - if hasattr(message, "__dict__") and not isinstance(message, dict): - # Convert object to dict for consistent handling - message_dict = {} - - # Get all attributes from the object - for attr_name in dir(message): - if not attr_name.startswith("_"): # Skip private attributes - try: - attr_value = getattr(message, attr_name) - if not callable(attr_value): # Skip methods - message_dict[attr_name] = attr_value - except: - pass - - logger.debug(f"Converted message dict: {message_dict}") - yield message_dict - else: - yield message + # Run the query with retry logic + retry_state = RetryState() + current_model = model + + while True: + try: + if current_model and current_model != model: + options.model = current_model + + async for message in query(prompt=prompt, options=options): + logger.debug(f"Raw SDK message type: {type(message)}") + logger.debug(f"Raw SDK message: {message}") + + if hasattr(message, "__dict__") and not isinstance(message, dict): + message_dict = {} + for attr_name in dir(message): + if not attr_name.startswith("_"): + try: + attr_value = getattr(message, attr_name) + if not callable(attr_value): + message_dict[attr_name] = attr_value + except: + pass + logger.debug(f"Converted message dict: {message_dict}") + yield message_dict + else: + yield message + + break # Success, exit retry loop + + except Exception as query_error: + error_str = str(query_error) + status_code = getattr(query_error, "status_code", None) + + retry_state.record_attempt(status_code) + + # Check for model fallback on overload + if current_model: + fallback = retry_state.get_fallback_model(current_model) + if fallback: + current_model = fallback + options.model = current_model + + if retry_state.should_retry(status_code=status_code, error=query_error): + await retry_delay(retry_state) + continue + + raise # Not retryable, propagate finally: # Restore original environment (if we changed anything) @@ -184,7 +217,6 @@ async def run_completion( except Exception as e: logger.error(f"Claude Agent SDK error: {e}") - # Yield error message in the expected format yield { "type": "result", "subtype": "error_during_execution", diff --git a/src/constants.py b/src/constants.py index 5921e77..3683a85 100644 --- a/src/constants.py +++ b/src/constants.py @@ -26,25 +26,18 @@ async def chat_endpoint(): ... import os -# Claude Agent SDK Tool Names -# These are the built-in tools available in the Claude Agent SDK -# See: https://docs.anthropic.com/en/docs/claude-code/sdk +# Claude Code tool inventory (sourced from open-sourced Claude Code CLI) CLAUDE_TOOLS = [ - "Task", # Launch agents for complex tasks - "Bash", # Execute bash commands - "Glob", # File pattern matching - "Grep", # Search file contents - "Read", # Read files - "Edit", # Edit files - "Write", # Write files - "NotebookEdit", # Edit Jupyter notebooks - "WebFetch", # Fetch web content - "TodoWrite", # Manage todo lists - "WebSearch", # Search the web - "BashOutput", # Get bash output - "KillShell", # Kill bash shells - "Skill", # Execute skills - "SlashCommand", # Execute slash commands + "Agent", "Task", "SendMessage", + "Bash", "BashOutput", "KillShell", + "Glob", "Grep", "Read", "Edit", "Write", "NotebookEdit", + "WebFetch", "WebSearch", + "TaskCreate", "TaskUpdate", "TaskGet", "TaskList", "TaskOutput", "TaskStop", + "EnterPlanMode", "ExitPlanMode", + "EnterWorktree", "ExitWorktree", + "ToolSearch", "AskUserQuestion", + "CronCreate", "CronDelete", "CronList", "RemoteTrigger", + "TodoWrite", "Skill", "SlashCommand", ] # Default tools to allow when tools are enabled @@ -58,43 +51,93 @@ async def chat_endpoint(): ... "Edit", ] -# Tools to disallow by default (potentially dangerous or slow) +# Tools to disallow by default (potentially dangerous or resource-intensive) DEFAULT_DISALLOWED_TOOLS = [ - "Task", # Can spawn sub-agents + "Agent", # Can spawn sub-agents + "Task", # Alias for Agent "WebFetch", # External network access "WebSearch", # External network access + "SendMessage", # External communication + "RemoteTrigger", # Remote execution ] -# Claude Models -# Models supported by Claude Agent SDK (as of February 2026) -# NOTE: Claude Agent SDK only supports Claude 4+ models, not Claude 3.x -CLAUDE_MODELS = [ - # Claude 4.6 (Latest - 2026) - "claude-opus-4-6", # Latest Opus 4.6 - # Claude 4.5 Family (Fall 2025) - "claude-opus-4-5-20251101", # Opus 4.5 - November version - "claude-sonnet-4-5-20250929", # Recommended - best coding model - "claude-haiku-4-5-20251001", # Fast & cheap - # Claude 4.1 - "claude-opus-4-1-20250805", # Upgraded Opus 4 - # Claude 4.0 Family (Original - May 2025) +# Model metadata (sourced from open-sourced Claude Code CLI) +# Only models that differ from the default are listed explicitly. +_DEFAULT_MODEL_META = {"context_window": 200_000, "default_max_output": 32_000, "max_output_limit": 64_000} + +_MODEL_OVERRIDES = { + "claude-opus-4-6": {"default_max_output": 64_000, "max_output_limit": 128_000}, + "claude-sonnet-4-6": {"max_output_limit": 128_000}, + "claude-3-5-sonnet-20241022": {"default_max_output": 8_192, "max_output_limit": 8_192}, + "claude-3-5-haiku-20241022": {"default_max_output": 8_192, "max_output_limit": 8_192}, +} + +# All supported model IDs (order: newest first) +_ALL_MODEL_IDS = [ + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-opus-4-5-20251101", + "claude-sonnet-4-5-20250929", + "claude-haiku-4-5-20251001", + "claude-opus-4-1-20250805", "claude-sonnet-4-20250514", "claude-opus-4-20250514", - # Claude 3.x Family - NOT SUPPORTED by Claude Agent SDK - # These models work with Anthropic API but NOT with Claude Code - # Uncomment only if using direct Anthropic API (not Claude Agent SDK) - # "claude-3-7-sonnet-20250219", - # "claude-3-5-sonnet-20241022", - # "claude-3-5-haiku-20241022", + "claude-3-7-sonnet-20250219", + "claude-3-5-sonnet-20241022", + "claude-3-5-haiku-20241022", ] -# Default model (recommended for most use cases) -# Can be overridden via DEFAULT_MODEL environment variable -DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "claude-sonnet-4-5-20250929") +MODEL_METADATA = { + model_id: {**_DEFAULT_MODEL_META, **_MODEL_OVERRIDES.get(model_id, {})} + for model_id in _ALL_MODEL_IDS +} -# Fast model (for speed/cost optimization) +# Derived from MODEL_METADATA so they can't drift out of sync +CLAUDE_MODELS = list(MODEL_METADATA.keys()) + +DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "claude-sonnet-4-6") FAST_MODEL = "claude-haiku-4-5-20251001" +# Pricing tiers (per million tokens, USD) +# Sourced from open-sourced Claude Code CLI (src/utils/modelCost.ts) +_PRICING_SONNET = {"input": 3.0, "output": 15.0, "cache_read": 0.30, "cache_write": 3.75} +_PRICING_OPUS = {"input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25} +_PRICING_OPUS_LEGACY = {"input": 15.0, "output": 75.0, "cache_read": 1.50, "cache_write": 18.75} +_PRICING_HAIKU_45 = {"input": 1.0, "output": 5.0, "cache_read": 0.10, "cache_write": 1.25} +_PRICING_HAIKU_35 = {"input": 0.80, "output": 4.0, "cache_read": 0.08, "cache_write": 1.00} + +MODEL_PRICING = { + "claude-sonnet-4-6": _PRICING_SONNET, + "claude-sonnet-4-5-20250929": _PRICING_SONNET, + "claude-sonnet-4-20250514": _PRICING_SONNET, + "claude-3-7-sonnet-20250219": _PRICING_SONNET, + "claude-3-5-sonnet-20241022": _PRICING_SONNET, + "claude-opus-4-6": _PRICING_OPUS, + "claude-opus-4-5-20251101": _PRICING_OPUS, + "claude-opus-4-1-20250805": _PRICING_OPUS_LEGACY, + "claude-opus-4-20250514": _PRICING_OPUS_LEGACY, + "claude-haiku-4-5-20251001": _PRICING_HAIKU_45, + "claude-3-5-haiku-20241022": _PRICING_HAIKU_35, +} + +# Web search cost (per request, all models) +WEB_SEARCH_COST_USD = 0.01 + +# Fallback model mapping: when an Opus model is overloaded, fall back to Sonnet +# Sourced from Claude Code's FallbackTriggeredError pattern +MODEL_FALLBACK_MAP = { + "claude-opus-4-6": "claude-sonnet-4-6", + "claude-opus-4-5-20251101": "claude-sonnet-4-5-20250929", + "claude-opus-4-1-20250805": "claude-sonnet-4-20250514", + "claude-opus-4-20250514": "claude-sonnet-4-20250514", +} + +# Effort levels supported by Claude API +VALID_EFFORT_LEVELS = {"low", "medium", "high", "max"} + +# Thinking modes supported by Claude API +VALID_THINKING_MODES = {"adaptive", "enabled", "disabled"} + # System Prompt Types SYSTEM_PROMPT_TYPE_TEXT = "text" SYSTEM_PROMPT_TYPE_PRESET = "preset" diff --git a/src/cost_tracker.py b/src/cost_tracker.py new file mode 100644 index 0000000..ad82b72 --- /dev/null +++ b/src/cost_tracker.py @@ -0,0 +1,175 @@ +""" +Cost tracking for Claude API usage. + +Calculates estimated costs per request and accumulates per session. +Pricing sourced from open-sourced Claude Code CLI (src/utils/modelCost.ts). +""" + +import asyncio +import logging +import time +from typing import Dict, Any, Optional +from dataclasses import dataclass, field + +from src.constants import MODEL_PRICING, WEB_SEARCH_COST_USD, SESSION_MAX_AGE_MINUTES + +logger = logging.getLogger(__name__) + +# Default pricing tier (Sonnet) for unknown models +_DEFAULT_PRICING = MODEL_PRICING.get("claude-sonnet-4-6", { + "input": 3.0, "output": 15.0, "cache_read": 0.30, "cache_write": 3.75, +}) + +_KEY_INPUT = "input" +_KEY_OUTPUT = "output" +_KEY_CACHE_READ = "cache_read" +_KEY_CACHE_WRITE = "cache_write" + + +@dataclass +class UsageRecord: + """Token usage for a single request.""" + input_tokens: int = 0 + output_tokens: int = 0 + cache_read_tokens: int = 0 + cache_creation_tokens: int = 0 + web_search_requests: int = 0 + + +@dataclass +class SessionCost: + """Accumulated cost for a session.""" + total_cost_usd: float = 0.0 + total_input_tokens: int = 0 + total_output_tokens: int = 0 + total_cache_read_tokens: int = 0 + total_cache_creation_tokens: int = 0 + total_web_search_requests: int = 0 + request_count: int = 0 + model_usage: Dict[str, Dict[str, Any]] = field(default_factory=dict) + last_updated: float = field(default_factory=time.time) + + +def calculate_cost(model: str, usage: UsageRecord) -> float: + """Calculate the cost in USD for a given model and usage.""" + pricing = MODEL_PRICING.get(model, _DEFAULT_PRICING) + + cost = 0.0 + cost += (usage.input_tokens / 1_000_000) * pricing[_KEY_INPUT] + cost += (usage.output_tokens / 1_000_000) * pricing[_KEY_OUTPUT] + cost += (usage.cache_read_tokens / 1_000_000) * pricing[_KEY_CACHE_READ] + cost += (usage.cache_creation_tokens / 1_000_000) * pricing[_KEY_CACHE_WRITE] + cost += usage.web_search_requests * WEB_SEARCH_COST_USD + + return cost + + +class CostTracker: + """Tracks costs per session. Uses asyncio.Lock for async-safe access.""" + + def __init__(self, max_age_minutes: int = SESSION_MAX_AGE_MINUTES): + self._sessions: Dict[str, SessionCost] = {} + self._lock = asyncio.Lock() + self._max_age_seconds = max_age_minutes * 60 + + async def record_usage( + self, + session_id: str, + model: str, + usage: UsageRecord, + ) -> float: + """Record usage for a session. Returns the cost for this request.""" + cost = calculate_cost(model, usage) + + async with self._lock: + if session_id not in self._sessions: + self._sessions[session_id] = SessionCost() + + session = self._sessions[session_id] + session.total_cost_usd += cost + session.total_input_tokens += usage.input_tokens + session.total_output_tokens += usage.output_tokens + session.total_cache_read_tokens += usage.cache_read_tokens + session.total_cache_creation_tokens += usage.cache_creation_tokens + session.total_web_search_requests += usage.web_search_requests + session.request_count += 1 + session.last_updated = time.time() + + if model not in session.model_usage: + session.model_usage[model] = { + "input_tokens": 0, + "output_tokens": 0, + "cost_usd": 0.0, + "requests": 0, + } + session.model_usage[model]["input_tokens"] += usage.input_tokens + session.model_usage[model]["output_tokens"] += usage.output_tokens + session.model_usage[model]["cost_usd"] += cost + session.model_usage[model]["requests"] += 1 + + logger.debug( + f"Session {session_id}: request cost=${cost:.6f}, " + f"total=${session.total_cost_usd:.6f}" + ) + return cost + + async def cleanup_expired(self) -> int: + """Remove sessions older than max_age. Returns count of removed sessions.""" + now = time.time() + async with self._lock: + expired = [ + sid for sid, s in self._sessions.items() + if (now - s.last_updated) > self._max_age_seconds + ] + for sid in expired: + del self._sessions[sid] + if expired: + logger.info(f"Cleaned up {len(expired)} expired cost tracker sessions") + return len(expired) + + async def get_session_cost(self, session_id: str) -> Optional[SessionCost]: + """Get accumulated cost for a session.""" + async with self._lock: + return self._sessions.get(session_id) + + async def get_session_summary(self, session_id: str) -> Dict[str, Any]: + """Get a summary dict for a session's costs.""" + async with self._lock: + session = self._sessions.get(session_id) + if not session: + return {"session_id": session_id, "total_cost_usd": 0.0, "request_count": 0} + + return { + "session_id": session_id, + "total_cost_usd": round(session.total_cost_usd, 6), + "total_input_tokens": session.total_input_tokens, + "total_output_tokens": session.total_output_tokens, + "total_cache_read_tokens": session.total_cache_read_tokens, + "total_cache_creation_tokens": session.total_cache_creation_tokens, + "total_web_search_requests": session.total_web_search_requests, + "request_count": session.request_count, + "model_usage": dict(session.model_usage), + } + + async def delete_session(self, session_id: str) -> bool: + """Remove cost tracking for a session.""" + async with self._lock: + if session_id in self._sessions: + del self._sessions[session_id] + return True + return False + + async def get_all_sessions_summary(self) -> Dict[str, Any]: + """Get cost summary across all sessions.""" + async with self._lock: + total_cost = sum(s.total_cost_usd for s in self._sessions.values()) + total_requests = sum(s.request_count for s in self._sessions.values()) + return { + "active_sessions": len(self._sessions), + "total_cost_usd": round(total_cost, 6), + "total_requests": total_requests, + } + + +# Global singleton instance +cost_tracker = CostTracker() diff --git a/src/main.py b/src/main.py index 04f5e77..e0d5f9b 100644 --- a/src/main.py +++ b/src/main.py @@ -51,9 +51,10 @@ rate_limit_exceeded_handler, rate_limit_endpoint, ) -from src.constants import CLAUDE_MODELS, CLAUDE_TOOLS, DEFAULT_ALLOWED_TOOLS +from src.constants import CLAUDE_MODELS, CLAUDE_TOOLS, DEFAULT_ALLOWED_TOOLS, SESSION_CLEANUP_INTERVAL_MINUTES from src.model_service import model_service from src.request_cache import request_cache +from src.cost_tracker import cost_tracker, UsageRecord # Load environment variables load_dotenv() @@ -210,8 +211,21 @@ async def lifespan(app: FastAPI): # Start session cleanup task session_manager.start_cleanup_task() + # Start cost tracker cleanup task (mirrors session cleanup interval) + async def cost_cleanup_loop(): + try: + while True: + await asyncio.sleep(SESSION_CLEANUP_INTERVAL_MINUTES * 60) + await cost_tracker.cleanup_expired() + except asyncio.CancelledError: + pass + + cost_cleanup_task = asyncio.get_running_loop().create_task(cost_cleanup_loop()) + yield + cost_cleanup_task.cancel() + # Cleanup on shutdown logger.info("Shutting down session manager...") session_manager.shutdown() @@ -410,6 +424,57 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE return JSONResponse(status_code=422, content=error_response) +def _build_claude_options( + request: ChatCompletionRequest, + claude_headers: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Build validated Claude SDK options from a request and optional headers. + + Shared by both the streaming and non-streaming code paths. + """ + claude_options = request.to_claude_options() + + if claude_headers: + claude_options.update(claude_headers) + + if claude_options.get("model"): + ParameterValidator.validate_model(claude_options["model"]) + + if request.max_tokens and claude_options.get("model"): + validated = ParameterValidator.validate_max_tokens( + claude_options["model"], request.max_tokens + ) + if validated is not None: + claude_options["max_tokens"] = validated + + if not request.enable_tools: + claude_options["disallowed_tools"] = CLAUDE_TOOLS + claude_options["max_turns"] = 1 + logger.info("Tools disabled (default behavior for OpenAI compatibility)") + else: + claude_options["allowed_tools"] = DEFAULT_ALLOWED_TOOLS + claude_options["permission_mode"] = "bypassPermissions" + logger.info(f"Tools enabled by user request: {DEFAULT_ALLOWED_TOOLS}") + + return claude_options + + +def _run_completion_kwargs(claude_options: Dict[str, Any], prompt: str, system_prompt: Optional[str], stream: bool) -> Dict[str, Any]: + """Extract run_completion keyword arguments from claude_options.""" + return { + "prompt": prompt, + "system_prompt": system_prompt, + "model": claude_options.get("model"), + "max_turns": claude_options.get("max_turns", 10), + "allowed_tools": claude_options.get("allowed_tools"), + "disallowed_tools": claude_options.get("disallowed_tools"), + "permission_mode": claude_options.get("permission_mode"), + "effort": claude_options.get("effort"), + "thinking": claude_options.get("thinking"), + "stream": stream, + } + + async def generate_streaming_response( request: ChatCompletionRequest, request_id: str, claude_headers: Optional[Dict[str, Any]] = None ) -> AsyncGenerator[str, None]: @@ -449,29 +514,7 @@ async def generate_streaming_response( if system_prompt: system_prompt = MessageAdapter.filter_content(system_prompt) - # Get Claude Agent SDK options from request - claude_options = request.to_claude_options() - - # Merge with Claude-specific headers if provided - if claude_headers: - claude_options.update(claude_headers) - - # Validate model - if claude_options.get("model"): - ParameterValidator.validate_model(claude_options["model"]) - - # Handle tools - disabled by default for OpenAI compatibility - if not request.enable_tools: - # Disable all tools by using CLAUDE_TOOLS constant - claude_options["disallowed_tools"] = CLAUDE_TOOLS - claude_options["max_turns"] = 1 # Single turn for Q&A - logger.info("Tools disabled (default behavior for OpenAI compatibility)") - else: - # Enable tools - use default safe subset (Read, Glob, Grep, Bash, Write, Edit) - claude_options["allowed_tools"] = DEFAULT_ALLOWED_TOOLS - # Set permission mode to bypass prompts (required for API/headless usage) - claude_options["permission_mode"] = "bypassPermissions" - logger.info(f"Tools enabled by user request: {DEFAULT_ALLOWED_TOOLS}") + claude_options = _build_claude_options(request, claude_headers) # Run Claude Code chunks_buffer = [] @@ -480,14 +523,7 @@ async def generate_streaming_response( json_mode_buffer = [] # Buffer for JSON mode - accumulate all content async for chunk in claude_cli.run_completion( - prompt=prompt, - system_prompt=system_prompt, - model=claude_options.get("model"), - max_turns=claude_options.get("max_turns", 10), - allowed_tools=claude_options.get("allowed_tools"), - disallowed_tools=claude_options.get("disallowed_tools"), - permission_mode=claude_options.get("permission_mode"), - stream=True, + **_run_completion_kwargs(claude_options, prompt, system_prompt, stream=True), ): chunks_buffer.append(chunk) @@ -681,6 +717,15 @@ async def generate_streaming_response( ) logger.debug(f"Estimated usage: {usage_data}") + await cost_tracker.record_usage( + session_id=actual_session_id or request_id, + model=request.model, + usage=UsageRecord( + input_tokens=token_usage["prompt_tokens"], + output_tokens=token_usage["completion_tokens"], + ), + ) + # Send final chunk with finish reason and optionally usage data final_chunk = ChatCompletionStreamResponse( id=request_id, @@ -795,41 +840,12 @@ async def chat_completions( if system_prompt: system_prompt = MessageAdapter.filter_content(system_prompt) - # Get Claude Agent SDK options from request - claude_options = request_body.to_claude_options() - - # Merge with Claude-specific headers - if claude_headers: - claude_options.update(claude_headers) - - # Validate model - if claude_options.get("model"): - ParameterValidator.validate_model(claude_options["model"]) - - # Handle tools - disabled by default for OpenAI compatibility - if not request_body.enable_tools: - # Disable all tools by using CLAUDE_TOOLS constant - claude_options["disallowed_tools"] = CLAUDE_TOOLS - claude_options["max_turns"] = 1 # Single turn for Q&A - logger.info("Tools disabled (default behavior for OpenAI compatibility)") - else: - # Enable tools - use default safe subset (Read, Glob, Grep, Bash, Write, Edit) - claude_options["allowed_tools"] = DEFAULT_ALLOWED_TOOLS - # Set permission mode to bypass prompts (required for API/headless usage) - claude_options["permission_mode"] = "bypassPermissions" - logger.info(f"Tools enabled by user request: {DEFAULT_ALLOWED_TOOLS}") + claude_options = _build_claude_options(request_body, claude_headers) # Collect all chunks chunks = [] async for chunk in claude_cli.run_completion( - prompt=prompt, - system_prompt=system_prompt, - model=claude_options.get("model"), - max_turns=claude_options.get("max_turns", 10), - allowed_tools=claude_options.get("allowed_tools"), - disallowed_tools=claude_options.get("disallowed_tools"), - permission_mode=claude_options.get("permission_mode"), - stream=False, + **_run_completion_kwargs(claude_options, prompt, system_prompt, stream=False), ): chunks.append(chunk) @@ -872,6 +888,15 @@ async def chat_completions( prompt_tokens = MessageAdapter.estimate_tokens(prompt) completion_tokens = MessageAdapter.estimate_tokens(assistant_content) + await cost_tracker.record_usage( + session_id=actual_session_id or request_id, + model=request_body.model, + usage=UsageRecord( + input_tokens=prompt_tokens, + output_tokens=completion_tokens, + ), + ) + # Create response response = ChatCompletionResponse( id=request_id, diff --git a/src/parameter_validator.py b/src/parameter_validator.py index 2bf1b70..4f8c5b5 100644 --- a/src/parameter_validator.py +++ b/src/parameter_validator.py @@ -5,7 +5,7 @@ import logging from typing import Dict, Any, List, Optional, Set from src.models import ChatCompletionRequest -from src.constants import CLAUDE_MODELS +from src.constants import CLAUDE_MODELS, MODEL_METADATA, VALID_EFFORT_LEVELS, VALID_THINKING_MODES logger = logging.getLogger(__name__) @@ -156,8 +156,55 @@ def extract_claude_headers(cls, headers: Dict[str, str]) -> Dict[str, Any]: f"Invalid X-Claude-Max-Thinking-Tokens header: {headers['x-claude-max-thinking-tokens']}" ) + # Extract effort level (low, medium, high, max) + if "x-claude-effort" in headers: + effort = headers["x-claude-effort"].lower().strip() + if effort in VALID_EFFORT_LEVELS: + claude_options["effort"] = effort + else: + logger.warning( + f"Invalid X-Claude-Effort header: '{effort}'. " + f"Valid values: {sorted(VALID_EFFORT_LEVELS)}" + ) + + # Extract thinking mode (adaptive, enabled, disabled) + if "x-claude-thinking" in headers: + thinking = headers["x-claude-thinking"].lower().strip() + if thinking in VALID_THINKING_MODES: + claude_options["thinking"] = thinking + else: + logger.warning( + f"Invalid X-Claude-Thinking header: '{thinking}'. " + f"Valid values: {sorted(VALID_THINKING_MODES)}" + ) + return claude_options + @classmethod + def validate_max_tokens(cls, model: str, requested_max_tokens: Optional[int]) -> Optional[int]: + """Validate and cap max_tokens based on model-specific limits. + + Returns the validated max_tokens value, or None if not specified. + Model metadata sourced from open-sourced Claude Code CLI. + """ + if requested_max_tokens is None: + return None + + metadata = MODEL_METADATA.get(model) + if not metadata: + # Unknown model, pass through without validation + return requested_max_tokens + + max_limit = metadata["max_output_limit"] + if requested_max_tokens > max_limit: + logger.warning( + f"max_tokens={requested_max_tokens} exceeds limit for {model} " + f"(max={max_limit}). Capping to {max_limit}." + ) + return max_limit + + return requested_max_tokens + class CompatibilityReporter: """Reports on OpenAI API compatibility and suggests alternatives.""" diff --git a/src/retry.py b/src/retry.py new file mode 100644 index 0000000..ff5f6f3 --- /dev/null +++ b/src/retry.py @@ -0,0 +1,128 @@ +""" +Retry logic with exponential backoff and model fallback. + +Patterns sourced from open-sourced Claude Code CLI (src/services/api/withRetry.ts). +""" + +import asyncio +import logging +import random +from typing import Optional + +from src.constants import MODEL_FALLBACK_MAP + +logger = logging.getLogger(__name__) + +# Retry configuration (matches Claude Code source) +DEFAULT_MAX_RETRIES = 10 +BASE_DELAY_MS = 500 +MAX_DELAY_MS = 30_000 +MAX_CONSECUTIVE_529_FOR_FALLBACK = 3 + + +class RetryConfig: + """Configuration for retry behavior.""" + + def __init__( + self, + max_retries: int = DEFAULT_MAX_RETRIES, + base_delay_ms: int = BASE_DELAY_MS, + max_delay_ms: int = MAX_DELAY_MS, + enable_model_fallback: bool = True, + ): + self.max_retries = max_retries + self.base_delay_ms = base_delay_ms + self.max_delay_ms = max_delay_ms + self.enable_model_fallback = enable_model_fallback + + +class RetryState: + """Tracks retry state across attempts for a single request.""" + + def __init__(self, config: Optional[RetryConfig] = None): + self.config = config or RetryConfig() + self.attempt = 0 + self.consecutive_529s = 0 + self.fallback_model: Optional[str] = None + + def calculate_delay(self, retry_after: Optional[float] = None) -> float: + """Calculate delay with exponential backoff and jitter. + + If a retry-after header value is provided, use it as a minimum. + """ + # Exponential backoff: base * 2^attempt + exp_delay = self.config.base_delay_ms * (2 ** self.attempt) + # Cap at max delay + exp_delay = min(exp_delay, self.config.max_delay_ms) + # Add jitter (0-25% of delay) + jitter = random.uniform(0, exp_delay * 0.25) + delay_ms = exp_delay + jitter + + # If retry-after is provided, use the larger value + if retry_after is not None: + retry_after_ms = retry_after * 1000 + delay_ms = max(delay_ms, retry_after_ms) + + return delay_ms / 1000 # Return seconds + + def should_retry(self, status_code: Optional[int] = None, error: Optional[Exception] = None) -> bool: + """Determine if the request should be retried.""" + if self.attempt >= self.config.max_retries: + return False + + if status_code is not None: + if status_code in (429, 529): + return True + if status_code >= 500: + return True + if status_code == 401: + return True + + if error is not None: + error_str = str(error).lower() + # Network errors are retryable + if any(term in error_str for term in ["timeout", "connection", "econnreset", "epipe"]): + return True + # Context overflow (400) -- only retry if the error message indicates it + if "context" in error_str and ("overflow" in error_str or "too long" in error_str): + return True + + return False + + def record_attempt(self, status_code: Optional[int] = None) -> None: + """Record an attempt and track consecutive 529s.""" + self.attempt += 1 + + if status_code == 529: + self.consecutive_529s += 1 + else: + self.consecutive_529s = 0 + + def should_fallback(self, model: str) -> bool: + """Check if we should fall back to a faster model after repeated 529s.""" + if not self.config.enable_model_fallback: + return False + if self.consecutive_529s < MAX_CONSECUTIVE_529_FOR_FALLBACK: + return False + return model in MODEL_FALLBACK_MAP + + def get_fallback_model(self, model: str) -> Optional[str]: + """Get the fallback model for the given model.""" + if self.should_fallback(model): + fallback = MODEL_FALLBACK_MAP.get(model) + if fallback: + self.fallback_model = fallback + logger.warning( + f"Falling back from {model} to {fallback} after " + f"{self.consecutive_529s} consecutive 529 errors" + ) + self.consecutive_529s = 0 + return fallback + return None + + +async def retry_delay(state: RetryState, retry_after: Optional[float] = None) -> None: + """Wait for the calculated retry delay.""" + delay = state.calculate_delay(retry_after) + logger.info(f"Retry attempt {state.attempt}/{state.config.max_retries}, waiting {delay:.1f}s") + await asyncio.sleep(delay) diff --git a/src/tool_manager.py b/src/tool_manager.py index a481d4a..6348847 100644 --- a/src/tool_manager.py +++ b/src/tool_manager.py @@ -30,20 +30,22 @@ class ToolMetadata: # Tool metadata database TOOL_METADATA: Dict[str, ToolMetadata] = { - "Task": ToolMetadata( - name="Task", - description="Launch specialized agents for complex, multi-step tasks", + "Agent": ToolMetadata( + name="Agent", + description="Spawn sub-agents for complex, multi-step tasks", category="agent", parameters={ "description": "Short description of the task", "prompt": "Detailed task instructions for the agent", "subagent_type": "Type of specialized agent to use", + "model": "Optional model override for the agent", + "isolation": "Isolation mode (e.g., worktree)", }, examples=[ "Launch a general-purpose agent to refactor code", "Use Explore agent to find API endpoints", ], - is_safe=False, # Can spawn sub-agents + is_safe=False, requires_network=False, ), "Bash": ToolMetadata( @@ -54,9 +56,10 @@ class ToolMetadata: "command": "The bash command to execute", "timeout": "Optional timeout in milliseconds", "run_in_background": "Run command in background", + "description": "Description of what the command does", }, examples=["Run npm install", "Execute git status", "List directory contents"], - is_safe=True, + is_safe=False, # Requires permission in Claude Code requires_network=False, ), "Glob": ToolMetadata( @@ -236,8 +239,164 @@ class ToolMetadata: is_safe=True, requires_network=False, ), + "SendMessage": ToolMetadata( + name="SendMessage", + description="Send messages to teammates or other agents", + category="agent", + parameters={"to": "Recipient agent or teammate", "message": "Message content"}, + examples=["Send status update to teammate"], + is_safe=False, + requires_network=False, + ), + "TaskCreate": ToolMetadata( + name="TaskCreate", + description="Create a new task for tracking work", + category="task", + parameters={"subject": "Task subject", "description": "Task description"}, + examples=["Create task to track implementation progress"], + is_safe=True, + requires_network=False, + ), + "TaskUpdate": ToolMetadata( + name="TaskUpdate", + description="Update an existing task status or details", + category="task", + parameters={"taskId": "Task ID", "status": "New status"}, + examples=["Mark task as completed"], + is_safe=True, + requires_network=False, + ), + "TaskGet": ToolMetadata( + name="TaskGet", + description="Get details of a specific task", + category="task", + parameters={"taskId": "Task ID to retrieve"}, + examples=["Get task details by ID"], + is_safe=True, + requires_network=False, + ), + "TaskList": ToolMetadata( + name="TaskList", + description="List all tasks", + category="task", + parameters={}, + examples=["List all active tasks"], + is_safe=True, + requires_network=False, + ), + "TaskOutput": ToolMetadata( + name="TaskOutput", + description="Get the output of a completed task", + category="task", + parameters={"taskId": "Task ID"}, + examples=["Retrieve output from finished task"], + is_safe=True, + requires_network=False, + ), + "TaskStop": ToolMetadata( + name="TaskStop", + description="Stop a running task", + category="task", + parameters={"taskId": "Task ID to stop"}, + examples=["Cancel a running background task"], + is_safe=True, + requires_network=False, + ), + "EnterPlanMode": ToolMetadata( + name="EnterPlanMode", + description="Enter plan mode for designing implementation approach", + category="planning", + parameters={}, + examples=["Enter plan mode before implementing a feature"], + is_safe=True, + requires_network=False, + ), + "ExitPlanMode": ToolMetadata( + name="ExitPlanMode", + description="Exit plan mode and present plan for approval", + category="planning", + parameters={}, + examples=["Exit plan mode after finishing design"], + is_safe=True, + requires_network=False, + ), + "EnterWorktree": ToolMetadata( + name="EnterWorktree", + description="Create an isolated git worktree for safe changes", + category="git", + parameters={"branch": "Branch name for the worktree"}, + examples=["Create isolated worktree for feature work"], + is_safe=True, + requires_network=False, + ), + "ExitWorktree": ToolMetadata( + name="ExitWorktree", + description="Exit and clean up a git worktree", + category="git", + parameters={}, + examples=["Clean up worktree after finishing work"], + is_safe=True, + requires_network=False, + ), + "ToolSearch": ToolMetadata( + name="ToolSearch", + description="Search for available tools by keyword or name", + category="discovery", + parameters={"query": "Search query for tools"}, + examples=["Find tools for file operations"], + is_safe=True, + requires_network=False, + ), + "AskUserQuestion": ToolMetadata( + name="AskUserQuestion", + description="Ask the user for input or clarification", + category="interaction", + parameters={"question": "Question to ask", "options": "Available choices"}, + examples=["Ask user to choose between approaches"], + is_safe=True, + requires_network=False, + ), + "CronCreate": ToolMetadata( + name="CronCreate", + description="Create a scheduled recurring task", + category="scheduling", + parameters={"schedule": "Cron schedule expression", "command": "Command to run"}, + examples=["Schedule a daily health check"], + is_safe=False, + requires_network=False, + ), + "CronDelete": ToolMetadata( + name="CronDelete", + description="Delete a scheduled task", + category="scheduling", + parameters={"cronId": "ID of the cron job to delete"}, + examples=["Remove a scheduled task"], + is_safe=True, + requires_network=False, + ), + "CronList": ToolMetadata( + name="CronList", + description="List all scheduled tasks", + category="scheduling", + parameters={}, + examples=["List all active cron jobs"], + is_safe=True, + requires_network=False, + ), + "RemoteTrigger": ToolMetadata( + name="RemoteTrigger", + description="Trigger remote agent execution", + category="scheduling", + parameters={"trigger": "Trigger configuration"}, + examples=["Trigger a remote agent to run a task"], + is_safe=False, + requires_network=True, + ), } +# Task is a backward-compatible alias for Agent -- share the same metadata +TOOL_METADATA["Task"] = TOOL_METADATA["Agent"] + @dataclass class ToolConfiguration: @@ -389,13 +548,8 @@ def get_stats(self) -> Dict: ), "session_configs": len(self.session_configs), "tool_categories": { - "file": len([t for t in TOOL_METADATA.values() if t.category == "file"]), - "system": len([t for t in TOOL_METADATA.values() if t.category == "system"]), - "web": len([t for t in TOOL_METADATA.values() if t.category == "web"]), - "productivity": len( - [t for t in TOOL_METADATA.values() if t.category == "productivity"] - ), - "agent": len([t for t in TOOL_METADATA.values() if t.category == "agent"]), + category: len([t for t in TOOL_METADATA.values() if t.category == category]) + for category in sorted(set(t.category for t in TOOL_METADATA.values())) }, } diff --git a/tests/test_cost_tracker_unit.py b/tests/test_cost_tracker_unit.py new file mode 100644 index 0000000..ee04fe3 --- /dev/null +++ b/tests/test_cost_tracker_unit.py @@ -0,0 +1,120 @@ +"""Unit tests for cost tracker module.""" + +import asyncio +import pytest +from src.cost_tracker import CostTracker, UsageRecord, calculate_cost + + +class TestCalculateCost: + """Tests for calculate_cost function (sync, no async needed).""" + + def test_sonnet_pricing(self): + usage = UsageRecord(input_tokens=1_000_000, output_tokens=1_000_000) + cost = calculate_cost("claude-sonnet-4-6", usage) + assert cost == pytest.approx(18.0) + + def test_opus_46_pricing(self): + usage = UsageRecord(input_tokens=1_000_000, output_tokens=1_000_000) + cost = calculate_cost("claude-opus-4-6", usage) + assert cost == pytest.approx(30.0) + + def test_haiku_pricing(self): + usage = UsageRecord(input_tokens=1_000_000, output_tokens=1_000_000) + cost = calculate_cost("claude-haiku-4-5-20251001", usage) + assert cost == pytest.approx(6.0) + + def test_cache_tokens(self): + usage = UsageRecord(cache_read_tokens=1_000_000, cache_creation_tokens=1_000_000) + cost = calculate_cost("claude-sonnet-4-6", usage) + assert cost == pytest.approx(4.05) + + def test_web_search(self): + usage = UsageRecord(web_search_requests=5) + cost = calculate_cost("claude-sonnet-4-6", usage) + assert cost == pytest.approx(0.05) + + def test_zero_usage(self): + usage = UsageRecord() + cost = calculate_cost("claude-sonnet-4-6", usage) + assert cost == 0.0 + + def test_unknown_model_uses_default(self): + usage = UsageRecord(input_tokens=1_000_000, output_tokens=1_000_000) + cost = calculate_cost("unknown-model-xyz", usage) + assert cost == pytest.approx(18.0) + + def test_small_usage(self): + usage = UsageRecord(input_tokens=100, output_tokens=50) + cost = calculate_cost("claude-sonnet-4-6", usage) + assert cost == pytest.approx(0.00105) + + +@pytest.mark.asyncio +class TestCostTracker: + """Tests for CostTracker class (async methods).""" + + async def test_record_usage(self): + tracker = CostTracker() + usage = UsageRecord(input_tokens=1000, output_tokens=500) + cost = await tracker.record_usage("session-1", "claude-sonnet-4-6", usage) + assert cost > 0 + + async def test_session_accumulation(self): + tracker = CostTracker() + usage = UsageRecord(input_tokens=1000, output_tokens=500) + await tracker.record_usage("session-1", "claude-sonnet-4-6", usage) + await tracker.record_usage("session-1", "claude-sonnet-4-6", usage) + + session = await tracker.get_session_cost("session-1") + assert session is not None + assert session.request_count == 2 + assert session.total_input_tokens == 2000 + assert session.total_output_tokens == 1000 + + async def test_multiple_sessions(self): + tracker = CostTracker() + usage = UsageRecord(input_tokens=1000, output_tokens=500) + await tracker.record_usage("session-1", "claude-sonnet-4-6", usage) + await tracker.record_usage("session-2", "claude-opus-4-6", usage) + + summary = await tracker.get_all_sessions_summary() + assert summary["active_sessions"] == 2 + assert summary["total_requests"] == 2 + + async def test_per_model_tracking(self): + tracker = CostTracker() + await tracker.record_usage("s1", "claude-sonnet-4-6", UsageRecord(input_tokens=100)) + await tracker.record_usage("s1", "claude-opus-4-6", UsageRecord(input_tokens=200)) + + summary = await tracker.get_session_summary("s1") + assert "claude-sonnet-4-6" in summary["model_usage"] + assert "claude-opus-4-6" in summary["model_usage"] + assert summary["model_usage"]["claude-sonnet-4-6"]["requests"] == 1 + assert summary["model_usage"]["claude-opus-4-6"]["requests"] == 1 + + async def test_delete_session(self): + tracker = CostTracker() + await tracker.record_usage("s1", "claude-sonnet-4-6", UsageRecord(input_tokens=100)) + assert await tracker.delete_session("s1") is True + assert await tracker.get_session_cost("s1") is None + assert await tracker.delete_session("s1") is False + + async def test_nonexistent_session_summary(self): + tracker = CostTracker() + summary = await tracker.get_session_summary("nonexistent") + assert summary["total_cost_usd"] == 0.0 + assert summary["request_count"] == 0 + + async def test_cleanup_expired(self): + tracker = CostTracker(max_age_minutes=0) # Expire immediately + await tracker.record_usage("s1", "claude-sonnet-4-6", UsageRecord(input_tokens=100)) + removed = await tracker.cleanup_expired() + assert removed == 1 + assert await tracker.get_session_cost("s1") is None + + async def test_cleanup_keeps_fresh_sessions(self): + tracker = CostTracker(max_age_minutes=60) + await tracker.record_usage("s1", "claude-sonnet-4-6", UsageRecord(input_tokens=100)) + removed = await tracker.cleanup_expired() + assert removed == 0 + assert await tracker.get_session_cost("s1") is not None diff --git a/tests/test_retry_unit.py b/tests/test_retry_unit.py new file mode 100644 index 0000000..ff44986 --- /dev/null +++ b/tests/test_retry_unit.py @@ -0,0 +1,146 @@ +"""Unit tests for retry logic module.""" + +import pytest +from src.retry import RetryConfig, RetryState + + +class TestRetryConfig: + """Tests for RetryConfig defaults.""" + + def test_default_config(self): + config = RetryConfig() + assert config.max_retries == 10 + assert config.base_delay_ms == 500 + assert config.max_delay_ms == 30_000 + assert config.enable_model_fallback is True + + def test_custom_config(self): + config = RetryConfig(max_retries=3, base_delay_ms=100, enable_model_fallback=False) + assert config.max_retries == 3 + assert config.base_delay_ms == 100 + assert config.enable_model_fallback is False + + +class TestRetryState: + """Tests for RetryState logic.""" + + def test_initial_state(self): + state = RetryState() + assert state.attempt == 0 + assert state.consecutive_529s == 0 + assert state.fallback_model is None + + def test_should_retry_429(self): + state = RetryState() + assert state.should_retry(status_code=429) is True + + def test_should_retry_529(self): + state = RetryState() + assert state.should_retry(status_code=529) is True + + def test_should_retry_500(self): + state = RetryState() + assert state.should_retry(status_code=500) is True + + def test_should_not_retry_200(self): + state = RetryState() + assert state.should_retry(status_code=200) is False + + def test_should_not_retry_404(self): + state = RetryState() + assert state.should_retry(status_code=404) is False + + def test_should_retry_timeout_error(self): + state = RetryState() + assert state.should_retry(error=Exception("Connection timeout")) is True + + def test_should_not_retry_generic_error(self): + state = RetryState() + assert state.should_retry(error=Exception("Invalid input")) is False + + def test_should_not_retry_400(self): + state = RetryState() + assert state.should_retry(status_code=400) is False + + def test_should_retry_context_overflow(self): + state = RetryState() + assert state.should_retry(error=Exception("context overflow: message too long")) is True + + def test_max_retries_exhausted(self): + config = RetryConfig(max_retries=2) + state = RetryState(config=config) + state.attempt = 2 + assert state.should_retry(status_code=429) is False + + def test_record_attempt_tracks_529s(self): + state = RetryState() + state.record_attempt(status_code=529) + assert state.consecutive_529s == 1 + assert state.attempt == 1 + + state.record_attempt(status_code=529) + assert state.consecutive_529s == 2 + + state.record_attempt(status_code=429) + assert state.consecutive_529s == 0 # Reset on non-529 + + def test_should_fallback_after_consecutive_529s(self): + state = RetryState() + state.consecutive_529s = 3 + assert state.should_fallback("claude-opus-4-6") is True + + def test_should_not_fallback_before_threshold(self): + state = RetryState() + state.consecutive_529s = 2 + assert state.should_fallback("claude-opus-4-6") is False + + def test_should_not_fallback_for_non_opus(self): + state = RetryState() + state.consecutive_529s = 3 + assert state.should_fallback("claude-sonnet-4-6") is False + + def test_should_not_fallback_when_disabled(self): + config = RetryConfig(enable_model_fallback=False) + state = RetryState(config=config) + state.consecutive_529s = 3 + assert state.should_fallback("claude-opus-4-6") is False + + def test_get_fallback_model(self): + state = RetryState() + state.consecutive_529s = 3 + fallback = state.get_fallback_model("claude-opus-4-6") + assert fallback == "claude-sonnet-4-6" + assert state.fallback_model == "claude-sonnet-4-6" + assert state.consecutive_529s == 0 # Reset after fallback + + def test_get_fallback_model_none_for_sonnet(self): + state = RetryState() + state.consecutive_529s = 3 + fallback = state.get_fallback_model("claude-sonnet-4-6") + assert fallback is None + + def test_calculate_delay_exponential(self): + state = RetryState(config=RetryConfig(base_delay_ms=1000)) + state.attempt = 0 + delay0 = state.calculate_delay() + state.attempt = 1 + delay1 = state.calculate_delay() + state.attempt = 2 + delay2 = state.calculate_delay() + # Each delay should roughly double (with jitter) + assert delay1 > delay0 + assert delay2 > delay1 + + def test_calculate_delay_capped(self): + config = RetryConfig(base_delay_ms=1000, max_delay_ms=5000) + state = RetryState(config=config) + state.attempt = 20 # Very high attempt + delay = state.calculate_delay() + # Should be capped at max + jitter (max 25% jitter) + assert delay <= 5.0 * 1.25 + + def test_calculate_delay_respects_retry_after(self): + state = RetryState(config=RetryConfig(base_delay_ms=100)) + state.attempt = 0 + delay = state.calculate_delay(retry_after=10.0) + assert delay >= 10.0 # Must be at least retry-after value diff --git a/tests/test_sdk_migration.py b/tests/test_sdk_migration.py index 6ad2d95..cec5140 100644 --- a/tests/test_sdk_migration.py +++ b/tests/test_sdk_migration.py @@ -74,7 +74,7 @@ def test_default_model_defined(self): from src.constants import DEFAULT_MODEL, CLAUDE_MODELS assert DEFAULT_MODEL in CLAUDE_MODELS - assert DEFAULT_MODEL == "claude-sonnet-4-5-20250929" + assert DEFAULT_MODEL == "claude-sonnet-4-6" def test_fast_model_defined(self): """Test that FAST_MODEL is set to fastest model.""" From 81502ea871328db78f15e5479b5d6e787e77730d Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Wed, 1 Apr 2026 14:29:59 -0400 Subject: [PATCH 09/38] feat: redesign landing page UI and update API docs (v2.5.1) - Replace generic landing page with clean utilitarian design - Fix GitHub URL to ttlequals0/claude-code-openai-wrapper - Fix OpenAPI docs version (was hardcoded 1.0.0, now dynamic) - Add all 25 endpoints to landing page grouped by category - Drop Pico CSS, use DM Sans + JetBrains Mono typography - Bump version to 2.5.1 --- CHANGELOG.md | 17 + src/__init__.py | 2 +- src/main.py | 1288 +++++++++++++++++++++++++---------------------- 3 files changed, 694 insertions(+), 613 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f02c7..50b4fee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to the Claude Code OpenAI Wrapper project will be documented The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.5.1] - 2026-04-01 + +### Fixed + +- **GitHub URL**: Corrected repository link from aaronlippold fork to ttlequals0/claude-code-openai-wrapper +- **OpenAPI Version**: FastAPI docs version now uses dynamic `__version__` instead of hardcoded "1.0.0" + +### Changed + +- **Landing Page Redesign**: Complete UI overhaul replacing generic AI-generated aesthetics with a clean, utilitarian developer dashboard + - Dropped Pico CSS in favor of custom minimal CSS + - Typography: DM Sans headings, JetBrains Mono for code paths + - Muted neutral color palette with method-specific badge colors (blue GET, amber POST, red DELETE) + - Removed gradient logo container, pulsing animations, and decorative section icons +- **Endpoint Documentation**: Landing page now lists all 25 endpoints grouped into 8 categories (Core API, Models, Sessions, Tools, MCP Servers, Cache, Auth/Debug, System) -- previously showed only 9 +- **Configuration Section**: Condensed from a full card into a compact footer line + ## [2.5.0] - 2026-03-31 ### Added diff --git a/src/__init__.py b/src/__init__.py index 46d5f9e..b9173a1 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ """Claude Code OpenAI Wrapper - A FastAPI-based OpenAI-compatible API for Claude Code.""" -__version__ = "2.5.0" +__version__ = "2.5.1" diff --git a/src/main.py b/src/main.py index e0d5f9b..2568e80 100644 --- a/src/main.py +++ b/src/main.py @@ -15,6 +15,7 @@ from fastapi.exceptions import RequestValidationError from pydantic import ValidationError from dotenv import load_dotenv +from src import __version__ from src.models import ( ChatCompletionRequest, @@ -238,7 +239,7 @@ async def cost_cleanup_loop(): app = FastAPI( title="Claude Code OpenAI API Wrapper", description="OpenAI-compatible API for Claude Code", - version="1.0.0", + version=__version__, lifespan=lifespan, ) @@ -1133,645 +1134,708 @@ async def version_info(request: Request): @app.get("/", response_class=HTMLResponse) async def root(): """Landing page with API documentation.""" - from src import __version__ - auth_info = get_claude_code_auth_info() auth_method = auth_info.get("method", "unknown") auth_valid = auth_info.get("status", {}).get("valid", False) status_color = "#22c55e" if auth_valid else "#ef4444" - status_text = "Connected" if auth_valid else "Not Connected" - - html_content = f""" - - - - - - - Claude Code OpenAI Wrapper - - - - + - - -
- -
-
-
-
- - - -
-
-
-

Claude Code OpenAI Wrapper

-

OpenAI-compatible API for Claude

-
+ }}); + + + +
+ +
+

Claude Code OpenAI Wrapper

+
+ v{__version__} + + + + +
+
+ +
+ + {status_text} + + Auth: {auth_method} +
+ +
+
Quick Start
+
+ +
+
+
+ +
+
Endpoints
+ +
+
Core API
+
+ POST + /v1/chat/completions + OpenAI-compatible chat +
+
+ POST + /v1/messages + Anthropic-compatible +
+
+ +
+
Models
+
+ + GET + /v1/models + List available models + +
+ +
-
- v{__version__} - - - - - - +
+
+ + GET + /v1/models/status + Model service status + +
+ +
-
- - -
-
-
- - {status_text} -
- Auth: {auth_method} +
+
+ + POST + /v1/models/refresh + Refresh from API + +
+

Requires api_key auth with ANTHROPIC_API_KEY set.

+ +
- - - -
-
- - Quick Start +
+ + +
+
Sessions
+
+ + GET + /v1/sessions + List active sessions + +
+ +
-
- -
+
+
+ + GET + /v1/sessions/stats + Session statistics + +
+ +
- - - -
-
- - API Endpoints +
+
+ GET + /v1/sessions/{{id}} + Get session by ID +
+
+ DELETE + /v1/sessions/{{id}} + Delete session +
+
+ +
+
Tools
+
+ + GET + /v1/tools + List available tools + +
+ +
- - -
- POST - /v1/chat/completions - OpenAI-compatible chat +
+
+ + GET + /v1/tools/config + Tool configuration + +
+ +
-
- POST - /v1/messages - Anthropic-compatible +
+
+ POST + /v1/tools/config + Update tool config +
+
+ + GET + /v1/tools/stats + Tool usage stats + +
+ +
- - -
- - GET - /v1/models - List models - -
- -
-
-
- -
- - GET - /v1/models/status - Model service status - -
- -
-
-
- -
- - POST - /v1/models/refresh - Refresh models from API - -
-

Requires CLAUDE_AUTH_METHOD=api_key with ANTHROPIC_API_KEY set.

- -
-
-
- -
- - GET - /v1/auth/status - Auth status - -
- -
-
-
- -
- - GET - /v1/sessions - Active sessions - -
- -
-
-
- -
- - GET - /health - Health check - -
- -
-
-
- -
- - GET - /version - API version - -
- -
-
-
- - - -
-
- - Configuration +
+
+ +
+
MCP Servers
+
+ + GET + /v1/mcp/servers + List MCP servers + +
+ +
-

Set CLAUDE_AUTH_METHOD to choose authentication:

-
-
- cli -

Claude CLI auth

-
-
- api_key -

ANTHROPIC_API_KEY

-
-
- bedrock -

AWS Bedrock

-
-
- vertex -

Google Vertex AI

-
+
+
+ POST + /v1/mcp/servers + Register server +
+
+ POST + /v1/mcp/connect + Connect to server +
+
+ POST + /v1/mcp/disconnect + Disconnect server +
+
+ + GET + /v1/mcp/stats + MCP statistics + +
+ +
- - - - - - - - """ +
+
+ +
+
Cache
+
+ + GET + /v1/cache/stats + Cache statistics + +
+ +
+
+
+
+ POST + /v1/cache/clear + Clear request cache +
+
+ +
+
Auth / Debug
+
+ + GET + /v1/auth/status + Auth status + +
+ +
+
+
+
+ POST + /v1/compatibility + Parameter compatibility check +
+
+ POST + /v1/debug/request + Debug request validation +
+
+ +
+
System
+
+ + GET + /health + Health check + +
+ +
+
+
+
+ + GET + /version + API version + +
+ +
+
+
+
+ + + + + + +""" return HTMLResponse(content=html_content) From da967137827cd78dcf77fe80119d35aa8c326cab Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Wed, 1 Apr 2026 17:20:21 -0400 Subject: [PATCH 10/38] docs: update README with JSON mode, full endpoint list, fix URLs - Add JSON response mode documentation with usage example - Expand API endpoints table from 14 to 25 entries, grouped by category - Fix Installation git clone URL (was RichardAtCT, now ttlequals0) - Bump version reference to 2.5.1 --- README.md | 74 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e53f608..496cc66 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ An OpenAI API-compatible wrapper for Claude Code, powered by the Claude Agent SD ## Version -**Current:** 2.5.0 +**Current:** 2.5.1 -What's new: +What's new in 2.5.x: +- Landing page redesigned with all 25 endpoints grouped by category - Model list updated from open-sourced Claude Code source (11 models, per-model metadata and pricing) - 33 tools tracked (up from 15), matching Claude Code's actual inventory - Cost tracking with authoritative per-model pricing @@ -68,7 +69,7 @@ The Claude Code CLI is bundled with the SDK. No separate Node.js or npm install ## Installation ```bash -git clone https://github.com/RichardAtCT/claude-code-openai-wrapper +git clone https://github.com/ttlequals0/claude-code-openai-wrapper cd claude-code-openai-wrapper poetry install cp .env.example .env # Edit with your preferences @@ -293,22 +294,75 @@ Sessions expire after 1 hour of inactivity. Manage them via: ## API Endpoints +### Core API | Endpoint | Method | Description | |----------|--------|-------------| | `/` | GET | Landing page with API explorer | | `/v1/chat/completions` | POST | OpenAI-compatible chat | | `/v1/messages` | POST | Anthropic-compatible messages | -| `/v1/models` | GET | List models | -| `/v1/models/refresh` | POST | Refresh models from API | + +### Models +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/v1/models` | GET | List available models | | `/v1/models/status` | GET | Model service status | -| `/v1/auth/status` | GET | Auth status | -| `/v1/sessions` | GET | List sessions | -| `/v1/sessions/{id}` | GET/DELETE | Session details / delete | +| `/v1/models/refresh` | POST | Refresh models from API | + +### Sessions +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/v1/sessions` | GET | List active sessions | | `/v1/sessions/stats` | GET | Session statistics | +| `/v1/sessions/{id}` | GET | Get session by ID | +| `/v1/sessions/{id}` | DELETE | Delete session | + +### Tools +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/v1/tools` | GET | List available tools | +| `/v1/tools/config` | GET | Get tool configuration | +| `/v1/tools/config` | POST | Update tool configuration | +| `/v1/tools/stats` | GET | Tool usage statistics | + +### MCP Servers +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/v1/mcp/servers` | GET | List MCP servers | +| `/v1/mcp/servers` | POST | Register MCP server | +| `/v1/mcp/connect` | POST | Connect to MCP server | +| `/v1/mcp/disconnect` | POST | Disconnect MCP server | +| `/v1/mcp/stats` | GET | MCP statistics | + +### Cache / Auth / System +| Endpoint | Method | Description | +|----------|--------|-------------| | `/v1/cache/stats` | GET | Cache statistics | -| `/v1/cache/clear` | POST | Clear cache | -| `/version` | GET | API version | +| `/v1/cache/clear` | POST | Clear request cache | +| `/v1/auth/status` | GET | Auth status | +| `/v1/compatibility` | POST | Parameter compatibility check | +| `/v1/debug/request` | POST | Debug request validation | | `/health` | GET | Health check | +| `/version` | GET | API version | + +## JSON Response Mode + +Force JSON output using the OpenAI-compatible `response_format` parameter: + +```python +response = client.chat.completions.create( + model="claude-sonnet-4-6", + messages=[{"role": "user", "content": "List 3 colors with hex codes"}], + response_format={"type": "json_object"} +) +``` + +When `response_format.type` is `json_object`, the wrapper: +- Injects system prompt instructions requiring valid JSON output +- Strips common preambles (e.g. "Here is the JSON:") from responses +- Uses balanced brace/bracket matching to extract JSON from mixed output +- Handles escaped quotes and nested structures correctly + +Works with both streaming and non-streaming responses. ## Limitations From 62e6c7412606aee689031f67c9d36c1db73b7c37 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Wed, 1 Apr 2026 17:31:09 -0400 Subject: [PATCH 11/38] docs: fix README accuracy issues and tighten language - Fix SDK version reference (removed pinned version, installed is 0.1.26) - Fix production command (main.py does not exist, use claude-wrapper) - Fix test command path (tests/test_endpoints.py not test_endpoints.py) - Fix MAX_TIMEOUT units in Docker table (ms not seconds, 600000 not 300) - Add missing env vars to config table (DEBUG_MODE, CORS_ORIGINS, etc.) - Update temperature/top_p limitation (now applied via system prompt) - Tighten prose, remove AI-ish phrasing - Sync pyproject.toml version to 2.5.1 --- README.md | 76 ++++++++++++++++++++------------------------------ pyproject.toml | 2 +- 2 files changed, 32 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 496cc66..28af223 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # Claude Code OpenAI API Wrapper -An OpenAI API-compatible wrapper for Claude Code, powered by the Claude Agent SDK v0.1.18. Use Claude Code with any OpenAI client library. +OpenAI API-compatible wrapper for Claude Code. Drop it in front of any OpenAI client library and talk to Claude instead. ## Version **Current:** 2.5.1 What's new in 2.5.x: -- Landing page redesigned with all 25 endpoints grouped by category +- Landing page redesigned with all endpoints grouped by category - Model list updated from open-sourced Claude Code source (11 models, per-model metadata and pricing) - 33 tools tracked (up from 15), matching Claude Code's actual inventory - Cost tracking with authoritative per-model pricing @@ -19,17 +19,7 @@ See [CHANGELOG.md](./CHANGELOG.md) for full history. ## Status -Production ready. Core features working and tested: -- Chat completions with Claude Agent SDK v0.1.18 -- Anthropic Messages API (`/v1/messages`) -- Streaming and non-streaming responses -- OpenAI SDK compatibility -- Multi-provider auth (API key, Bedrock, Vertex AI, CLI) -- System prompt support, model selection with validation -- Tools disabled by default for speed; opt-in with `enable_tools: true` -- Cost and token tracking -- Session continuity across requests -- Interactive landing page with API explorer +Production ready. 566 tests passing. Streaming works. Sessions work. JSON mode works. Tools are off by default for speed -- pass `enable_tools: true` to turn them on. Auth supports API key, Bedrock, Vertex AI, and CLI. ## Quick Start @@ -47,10 +37,10 @@ export ANTHROPIC_API_KEY=your-api-key poetry run uvicorn src.main:app --reload --port 8000 # Test -poetry run python test_endpoints.py +poetry run pytest tests/ ``` -Your OpenAI-compatible Claude Code API is now running on `http://localhost:8000`. +Server is at `http://localhost:8000`. Point your OpenAI client there. ## Prerequisites @@ -64,7 +54,7 @@ Your OpenAI-compatible Claude Code API is now running on `http://localhost:8000` - `claude auth login` (CLI auth) - AWS Bedrock or Google Vertex AI (see Configuration) -The Claude Code CLI is bundled with the SDK. No separate Node.js or npm install needed. +The Claude Code CLI comes bundled with the SDK. No Node.js or npm needed. ## Installation @@ -87,8 +77,9 @@ Edit `.env`: # API_KEY=your-optional-api-key PORT=8000 -MAX_TIMEOUT=600000 # milliseconds -# CLAUDE_CWD=/path/to/workspace # defaults to isolated temp dir +MAX_TIMEOUT=600000 # milliseconds (10 min default) +# CLAUDE_CWD=/path/to/workspace # defaults to isolated temp dir +# DEFAULT_MODEL=claude-sonnet-4-6 # override default model ``` ### Working Directory @@ -110,7 +101,7 @@ Per-IP rate limiting is built in. Defaults: | `/v1/auth/status` | 10/min | | `/health` | 30/min | -Configure via environment variables: `RATE_LIMIT_ENABLED`, `RATE_LIMIT_CHAT_PER_MINUTE`, etc. +Override with env vars: `RATE_LIMIT_ENABLED`, `RATE_LIMIT_CHAT_PER_MINUTE`, etc. ## Running the Server @@ -119,7 +110,7 @@ Configure via environment variables: `RATE_LIMIT_ENABLED`, `RATE_LIMIT_CHAT_PER_ poetry run uvicorn src.main:app --reload --port 8000 # Production -poetry run python main.py +poetry run claude-wrapper ``` ## Docker @@ -155,17 +146,21 @@ services: - ~/.claude:/root/.claude environment: - PORT=8000 - - MAX_TIMEOUT=600 + - MAX_TIMEOUT=600000 restart: unless-stopped ``` | Variable | Description | Default | |----------|-------------|---------| | `PORT` | Server port | `8000` | -| `MAX_TIMEOUT` | Request timeout (seconds) | `300` | +| `MAX_TIMEOUT` | Request timeout (ms) | `600000` (10 min) | | `CLAUDE_CWD` | Working directory | temp dir | | `CLAUDE_AUTH_METHOD` | `cli`, `api_key`, `bedrock`, `vertex` | auto-detect | | `ANTHROPIC_API_KEY` | Direct API key | - | +| `DEBUG_MODE` | Enable debug logging | `false` | +| `CORS_ORIGINS` | Allowed CORS origins (JSON array) | `["*"]` | +| `REQUEST_CACHE_ENABLED` | Enable request dedup cache | `false` | +| `DEFAULT_MODEL` | Override default model | `claude-sonnet-4-6` | ## Usage Examples @@ -224,7 +219,7 @@ for chunk in stream: ### Claude-specific headers -Pass Claude SDK options via custom HTTP headers: +Claude-specific options via HTTP headers: | Header | Values | Description | |--------|--------|-------------| @@ -237,7 +232,7 @@ Pass Claude SDK options via custom HTTP headers: ## Supported Models -All model IDs, context windows, and pricing sourced from the open-sourced Claude Code CLI. +Model IDs, context windows, and pricing pulled from the open-sourced Claude Code CLI. ### Claude 4.6 (Latest) | Model | Context | Max Output | Input $/MTok | Output $/MTok | @@ -268,7 +263,7 @@ All model IDs, context windows, and pricing sourced from the open-sourced Claude ## Session Continuity -Maintain conversation context across requests by including a `session_id`: +Pass a `session_id` to keep conversation context across requests: ```python # Start a conversation @@ -286,7 +281,7 @@ response2 = client.chat.completions.create( ) ``` -Sessions expire after 1 hour of inactivity. Manage them via: +Sessions expire after 1 hour of inactivity. Management endpoints: - `GET /v1/sessions` -- list active sessions - `GET /v1/sessions/{id}` -- session details - `DELETE /v1/sessions/{id}` -- delete session @@ -346,7 +341,7 @@ Sessions expire after 1 hour of inactivity. Manage them via: ## JSON Response Mode -Force JSON output using the OpenAI-compatible `response_format` parameter: +Set `response_format` to get JSON back: ```python response = client.chat.completions.create( @@ -356,19 +351,14 @@ response = client.chat.completions.create( ) ``` -When `response_format.type` is `json_object`, the wrapper: -- Injects system prompt instructions requiring valid JSON output -- Strips common preambles (e.g. "Here is the JSON:") from responses -- Uses balanced brace/bracket matching to extract JSON from mixed output -- Handles escaped quotes and nested structures correctly - -Works with both streaming and non-streaming responses. +With `json_object` mode, the wrapper adds system prompt instructions for JSON output, strips preambles like "Here is the JSON:", and uses brace-matching extraction as a fallback. Works streaming and non-streaming. ## Limitations - Images in messages are converted to text placeholders - OpenAI-style function calling not supported (tools auto-execute based on prompts) -- `temperature`, `top_p`, `presence_penalty`, `frequency_penalty` are accepted but not passed to Claude SDK +- `temperature` and `top_p` are applied via system prompt instructions (best-effort approximation, not native SDK parameters) +- `presence_penalty` and `frequency_penalty` are accepted but ignored - Multiple responses (`n > 1`) not supported ## Testing @@ -378,16 +368,12 @@ Works with both streaming and non-streaming responses. poetry run pytest tests/ # Quick endpoint test (server must be running) -poetry run python test_endpoints.py +poetry run python tests/test_endpoints.py ``` -## Terms Compliance - -This wrapper requires your own Claude subscription or API access. It translates request formats -- it does not provide Claude access itself. +## Terms -- Uses the official Claude Agent SDK -- Each user authenticates individually (no credential sharing) -- No reselling, no data harvesting +You need your own Claude subscription or API access. This wrapper translates request formats -- it does not provide Claude access. | Use Case | Recommended Auth | |----------|-----------------| @@ -395,12 +381,12 @@ This wrapper requires your own Claude subscription or API access. It translates | Business / commercial | API Key, Bedrock, or Vertex AI | | High-scale | Bedrock or Vertex AI | -See [Anthropic's Terms of Service](https://www.anthropic.com/legal) for details. +See [Anthropic's Terms of Service](https://www.anthropic.com/legal). -## Licence +## License MIT ## Contributing -Contributions welcome. Open an issue or submit a pull request. +PRs welcome. diff --git a/pyproject.toml b/pyproject.toml index a1f8f00..89b703d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "claude-code-openai-wrapper" -version = "2.5.0" +version = "2.5.1" description = "OpenAI API-compatible wrapper for Claude Code" authors = ["Richard Atkinson "] readme = "README.md" From e6b3f3b6759bbcfc3b92a405b25e189debf75d25 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Wed, 1 Apr 2026 17:48:35 -0400 Subject: [PATCH 12/38] docs: add Docker Hub image info to README --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 28af223..a3b46b2 100644 --- a/README.md +++ b/README.md @@ -115,22 +115,24 @@ poetry run claude-wrapper ## Docker -```bash -# Build -docker build -t claude-wrapper:latest . +Pre-built image on Docker Hub: `ttlequals0/claude-code-openai-wrapper` -# Run +```bash +# Pull and run docker run -d -p 8000:8000 \ -v ~/.claude:/root/.claude \ --name claude-wrapper \ - claude-wrapper:latest + ttlequals0/claude-code-openai-wrapper:latest # With custom workspace docker run -d -p 8000:8000 \ -v ~/.claude:/root/.claude \ -v /path/to/project:/workspace \ -e CLAUDE_CWD=/workspace \ - claude-wrapper:latest + ttlequals0/claude-code-openai-wrapper:2.5.1 + +# Or build locally +docker build -t claude-wrapper:latest . ``` Docker Compose: @@ -139,7 +141,7 @@ Docker Compose: version: '3.8' services: claude-wrapper: - build: . + image: ttlequals0/claude-code-openai-wrapper:latest ports: - "8000:8000" volumes: From d80b463651d4b008bc281e8f44201cbc07503a36 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Wed, 1 Apr 2026 22:32:51 -0400 Subject: [PATCH 13/38] fix: remove fake tools, add 11 real tools from Claude Code source (v2.5.2) - Remove BashOutput, KillShell, SlashCommand (not in Claude Code registry) - Add Brief, Config, ListPeers, REPL, Sleep, Monitor, SendUserFile, PushNotification, ListMcpResources, ReadMcpResource, VerifyPlanExecution - Tool count: 33 -> 41, verified against Claude Code src/tools.ts --- CHANGELOG.md | 14 ++++ README.md | 4 +- pyproject.toml | 2 +- src/__init__.py | 2 +- src/constants.py | 11 +-- src/tool_manager.py | 119 +++++++++++++++++++++++++------- tests/test_tool_manager_unit.py | 4 +- 7 files changed, 121 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b4fee..d121603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to the Claude Code OpenAI Wrapper project will be documented The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.5.2] - 2026-04-01 + +### Fixed + +- **Removed fake tools**: Removed BashOutput, KillShell, and SlashCommand from tool inventory -- these do not exist in Claude Code's tool registry and were diversions in the source + +### Added + +- **11 real tools**: Added Brief, Config, ListPeers, REPL, Sleep, Monitor, SendUserFile, PushNotification, ListMcpResources, ReadMcpResource, VerifyPlanExecution -- all verified against Claude Code source (`src/tools.ts:getAllBaseTools()`) + +### Changed + +- Tool count: 33 -> 41 (removed 3 fake, added 11 real) + ## [2.5.1] - 2026-04-01 ### Fixed diff --git a/README.md b/README.md index a3b46b2..9a450d3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ OpenAI API-compatible wrapper for Claude Code. Drop it in front of any OpenAI cl What's new in 2.5.x: - Landing page redesigned with all endpoints grouped by category - Model list updated from open-sourced Claude Code source (11 models, per-model metadata and pricing) -- 33 tools tracked (up from 15), matching Claude Code's actual inventory +- 41 tools tracked, verified against Claude Code source - Cost tracking with authoritative per-model pricing - Retry logic with exponential backoff and model fallback - `X-Claude-Effort` and `X-Claude-Thinking` headers for fine-grained control @@ -129,7 +129,7 @@ docker run -d -p 8000:8000 \ -v ~/.claude:/root/.claude \ -v /path/to/project:/workspace \ -e CLAUDE_CWD=/workspace \ - ttlequals0/claude-code-openai-wrapper:2.5.1 + ttlequals0/claude-code-openai-wrapper:2.5.2 # Or build locally docker build -t claude-wrapper:latest . diff --git a/pyproject.toml b/pyproject.toml index 89b703d..02dfdcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "claude-code-openai-wrapper" -version = "2.5.1" +version = "2.5.2" description = "OpenAI API-compatible wrapper for Claude Code" authors = ["Richard Atkinson "] readme = "README.md" diff --git a/src/__init__.py b/src/__init__.py index b9173a1..222c599 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ """Claude Code OpenAI Wrapper - A FastAPI-based OpenAI-compatible API for Claude Code.""" -__version__ = "2.5.1" +__version__ = "2.5.2" diff --git a/src/constants.py b/src/constants.py index 3683a85..9eb0d2c 100644 --- a/src/constants.py +++ b/src/constants.py @@ -28,16 +28,19 @@ async def chat_endpoint(): ... # Claude Code tool inventory (sourced from open-sourced Claude Code CLI) CLAUDE_TOOLS = [ - "Agent", "Task", "SendMessage", - "Bash", "BashOutput", "KillShell", + "Agent", "Task", "SendMessage", "ListPeers", + "Bash", "Glob", "Grep", "Read", "Edit", "Write", "NotebookEdit", "WebFetch", "WebSearch", "TaskCreate", "TaskUpdate", "TaskGet", "TaskList", "TaskOutput", "TaskStop", - "EnterPlanMode", "ExitPlanMode", + "EnterPlanMode", "ExitPlanMode", "VerifyPlanExecution", "EnterWorktree", "ExitWorktree", "ToolSearch", "AskUserQuestion", "CronCreate", "CronDelete", "CronList", "RemoteTrigger", - "TodoWrite", "Skill", "SlashCommand", + "TodoWrite", "Skill", "Brief", "Config", + "REPL", "Sleep", "Monitor", + "SendUserFile", "PushNotification", + "ListMcpResources", "ReadMcpResource", ] # Default tools to allow when tools are enabled diff --git a/src/tool_manager.py b/src/tool_manager.py index 6348847..94fe588 100644 --- a/src/tool_manager.py +++ b/src/tool_manager.py @@ -200,42 +200,111 @@ class ToolMetadata: is_safe=True, requires_network=True, ), - "BashOutput": ToolMetadata( - name="BashOutput", - description="Retrieve output from background bash shells", + "Skill": ToolMetadata( + name="Skill", + description="Execute specialized skills and slash commands", + category="productivity", + parameters={"skill": "Skill name to execute", "args": "Optional arguments"}, + examples=["Execute PDF processing skill", "Run commit skill"], + is_safe=True, + requires_network=False, + ), + "Brief": ToolMetadata( + name="Brief", + description="Control output verbosity level", + category="output", + parameters={"level": "Verbosity level"}, + examples=["Set brief output mode"], + is_safe=True, + requires_network=False, + ), + "Config": ToolMetadata( + name="Config", + description="Read or write Claude Code configuration", category="system", - parameters={ - "bash_id": "ID of the background shell", - "filter": "Regex to filter output lines", - }, - examples=["Check output of running process", "Monitor long-running command"], + parameters={"action": "read or write", "key": "Config key", "value": "Config value"}, + examples=["Read current config", "Update a setting"], + is_safe=True, + requires_network=False, + ), + "ListPeers": ToolMetadata( + name="ListPeers", + description="List peer agents in multi-agent setups", + category="agent", + parameters={}, + examples=["List available peer agents"], is_safe=True, requires_network=False, ), - "KillShell": ToolMetadata( - name="KillShell", - description="Kill a running background bash shell", + "REPL": ToolMetadata( + name="REPL", + description="Execute code in a REPL environment", + category="system", + parameters={"code": "Code to execute", "language": "Programming language"}, + examples=["Run Python code in REPL"], + is_safe=False, + requires_network=False, + ), + "Sleep": ToolMetadata( + name="Sleep", + description="Pause execution for a specified duration", category="system", - parameters={"shell_id": "ID of the shell to kill"}, - examples=["Stop long-running background process"], + parameters={"duration": "Duration in milliseconds"}, + examples=["Wait before retrying an operation"], is_safe=True, requires_network=False, ), - "Skill": ToolMetadata( - name="Skill", - description="Execute specialized skills", - category="productivity", - parameters={"command": "Skill name to execute"}, - examples=["Execute PDF processing skill", "Run Excel manipulation skill"], + "Monitor": ToolMetadata( + name="Monitor", + description="Monitor running processes and background tasks", + category="system", + parameters={"target": "Process or task to monitor"}, + examples=["Monitor a background build process"], is_safe=True, requires_network=False, ), - "SlashCommand": ToolMetadata( - name="SlashCommand", - description="Execute custom slash commands", - category="productivity", - parameters={"command": "Slash command with arguments"}, - examples=["Run custom code review command", "Execute project-specific workflow"], + "SendUserFile": ToolMetadata( + name="SendUserFile", + description="Send a file to the user", + category="file", + parameters={"path": "Path to the file to send"}, + examples=["Send generated report to user"], + is_safe=True, + requires_network=False, + ), + "PushNotification": ToolMetadata( + name="PushNotification", + description="Send push notifications to the user", + category="notification", + parameters={"title": "Notification title", "body": "Notification body"}, + examples=["Notify user that a long task completed"], + is_safe=True, + requires_network=False, + ), + "ListMcpResources": ToolMetadata( + name="ListMcpResources", + description="List available MCP server resources", + category="mcp", + parameters={"server": "MCP server name"}, + examples=["List resources from a connected MCP server"], + is_safe=True, + requires_network=True, + ), + "ReadMcpResource": ToolMetadata( + name="ReadMcpResource", + description="Read a specific MCP server resource", + category="mcp", + parameters={"server": "MCP server name", "uri": "Resource URI"}, + examples=["Read a resource from an MCP server"], + is_safe=True, + requires_network=True, + ), + "VerifyPlanExecution": ToolMetadata( + name="VerifyPlanExecution", + description="Verify that a plan was executed correctly", + category="planning", + parameters={"plan_id": "ID of the plan to verify"}, + examples=["Check that all plan steps were completed"], is_safe=True, requires_network=False, ), diff --git a/tests/test_tool_manager_unit.py b/tests/test_tool_manager_unit.py index 78fea02..1a77246 100644 --- a/tests/test_tool_manager_unit.py +++ b/tests/test_tool_manager_unit.py @@ -403,7 +403,7 @@ def test_file_tools_category(self): def test_system_tools_category(self): """System tools are correctly categorized.""" - system_tools = ["Bash", "BashOutput", "KillShell"] + system_tools = ["Bash", "Config", "REPL", "Sleep", "Monitor"] for tool_name in system_tools: assert TOOL_METADATA[tool_name].category == "system" @@ -416,7 +416,7 @@ def test_web_tools_category(self): def test_productivity_tools_category(self): """Productivity tools are correctly categorized.""" - productivity_tools = ["TodoWrite", "Skill", "SlashCommand"] + productivity_tools = ["TodoWrite", "Skill"] for tool_name in productivity_tools: assert TOOL_METADATA[tool_name].category == "productivity" From 6dd0acd9fcd6bdc3ab9a1d941baddf023d2032f2 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Thu, 2 Apr 2026 18:57:17 -0400 Subject: [PATCH 14/38] feat: function calling, JSON schema, fence stripping, CPU watchdog (v2.6.0) - OpenAI function calling simulation via system prompt injection and response parsing (tools/tool_choice parameters, multi-turn support) - JSON schema in response_format (type=json_schema with schema definition) - Real-time streaming markdown fence stripping (JsonFenceStripper) - CPU watchdog for Docker/Linux (WATCHDOG_ENABLED=true to enable) - New models: ToolCall, FunctionCall, ToolDefinition, JsonSchema - Message model extended with tool role, tool_calls, tool_call_id --- CHANGELOG.md | 26 ++++ README.md | 43 +++++- pyproject.toml | 2 +- src/__init__.py | 2 +- src/cpu_watchdog.py | 99 ++++++++++++ src/function_calling.py | 145 ++++++++++++++++++ src/main.py | 228 +++++++++++++++++++++++----- src/message_adapter.py | 84 +++++++++- src/models.py | 55 ++++++- tests/test_cpu_watchdog_unit.py | 41 +++++ tests/test_fence_stripper_unit.py | 55 +++++++ tests/test_function_calling_unit.py | 174 +++++++++++++++++++++ 12 files changed, 896 insertions(+), 58 deletions(-) create mode 100644 src/cpu_watchdog.py create mode 100644 src/function_calling.py create mode 100644 tests/test_cpu_watchdog_unit.py create mode 100644 tests/test_fence_stripper_unit.py create mode 100644 tests/test_function_calling_unit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d121603..b0853c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to the Claude Code OpenAI Wrapper project will be documented The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.0] - 2026-04-02 + +### Added + +- **OpenAI Function Calling** (`src/function_calling.py`): Simulates OpenAI tool/function calling via system prompt injection and response parsing + - Converts `tools` array and `tool_choice` into Claude-compatible system prompts + - Parses Claude's response for ```tool_calls``` blocks and bare JSON arrays + - Returns OpenAI-format `tool_calls` in the response with generated call IDs + - Handles multi-turn conversations: assistant tool_calls and tool result messages converted to text +- **JSON Schema in response_format**: Support for `response_format.type = "json_schema"` with schema definition + - Schema injected into user prompt (not system_prompt) for SDK subprocess compatibility + - Includes explicit rules for required properties, exact names, and exact types +- **Streaming Fence Stripping** (`JsonFenceStripper` in `src/message_adapter.py`): Real-time removal of markdown ```json fences during streaming + - Hold-back buffers detect and strip opening/closing fences across chunk boundaries + - Replaces full-buffer strategy for JSON streaming -- chunks flow in real-time +- **CPU Watchdog** (`src/cpu_watchdog.py`): Background CPU monitor for Docker/Linux deployments + - Reads /proc/self/stat every 30s, sends SIGTERM after 3 consecutive strikes above 80% CPU + - Disabled by default, enable with `WATCHDOG_ENABLED=true` + - Configurable interval, threshold, and strike count via env vars + +### Changed + +- **Message model**: Added `tool` role, `tool_calls`, `tool_call_id` fields for function calling support +- **ResponseFormat model**: Extended with `json_schema` type and `JsonSchema` model +- **Choice/StreamChoice**: Added `tool_calls` finish reason + ## [2.5.2] - 2026-04-01 ### Fixed diff --git a/README.md b/README.md index 9a450d3..31c9001 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,13 @@ OpenAI API-compatible wrapper for Claude Code. Drop it in front of any OpenAI cl ## Version -**Current:** 2.5.1 +**Current:** 2.6.0 + +What's new in 2.6.0: +- OpenAI function calling simulation (tools/tool_choice parameters) +- JSON schema support in response_format +- Real-time streaming fence stripping for JSON responses +- CPU watchdog for Docker deployments What's new in 2.5.x: - Landing page redesigned with all endpoints grouped by category @@ -129,7 +135,7 @@ docker run -d -p 8000:8000 \ -v ~/.claude:/root/.claude \ -v /path/to/project:/workspace \ -e CLAUDE_CWD=/workspace \ - ttlequals0/claude-code-openai-wrapper:2.5.2 + ttlequals0/claude-code-openai-wrapper:2.6.0 # Or build locally docker build -t claude-wrapper:latest . @@ -341,6 +347,39 @@ Sessions expire after 1 hour of inactivity. Management endpoints: | `/health` | GET | Health check | | `/version` | GET | API version | +## Function Calling + +Pass OpenAI-format tool definitions. The wrapper injects them into Claude's system prompt and parses structured responses back into `tool_calls` format. + +```python +response = client.chat.completions.create( + model="claude-sonnet-4-6", + messages=[{"role": "user", "content": "What's the weather in NYC?"}], + tools=[{ + "type": "function", + "function": { + "name": "get_weather", + "description": "Get current weather for a location", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + }, + }], + tool_choice="auto", +) + +# Response includes tool_calls when Claude decides to call a function +if response.choices[0].finish_reason == "tool_calls": + for tc in response.choices[0].message.tool_calls: + print(f"Call: {tc.function.name}({tc.function.arguments})") +``` + +Supports `tool_choice`: `"auto"` (default), `"required"`, `"none"`, or `{"type": "function", "function": {"name": "..."}}`. + +Multi-turn tool conversations work -- pass assistant messages with `tool_calls` and `tool` role result messages back. The wrapper converts them to text for Claude. + ## JSON Response Mode Set `response_format` to get JSON back: diff --git a/pyproject.toml b/pyproject.toml index 02dfdcc..d311d14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "claude-code-openai-wrapper" -version = "2.5.2" +version = "2.6.0" description = "OpenAI API-compatible wrapper for Claude Code" authors = ["Richard Atkinson "] readme = "README.md" diff --git a/src/__init__.py b/src/__init__.py index 222c599..a27e737 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ """Claude Code OpenAI Wrapper - A FastAPI-based OpenAI-compatible API for Claude Code.""" -__version__ = "2.5.2" +__version__ = "2.6.0" diff --git a/src/cpu_watchdog.py b/src/cpu_watchdog.py new file mode 100644 index 0000000..5ea3e5c --- /dev/null +++ b/src/cpu_watchdog.py @@ -0,0 +1,99 @@ +"""CPU watchdog for detecting and recovering from epoll busy-loops.""" + +import asyncio +import logging +import os +import signal +import sys +import time + +logger = logging.getLogger(__name__) + +# Configurable via environment variables +WATCHDOG_ENABLED = os.getenv("WATCHDOG_ENABLED", "false").lower() == "true" +WATCHDOG_INTERVAL = int(os.getenv("WATCHDOG_INTERVAL", "30")) +WATCHDOG_CPU_THRESHOLD = float(os.getenv("WATCHDOG_CPU_THRESHOLD", "80")) +WATCHDOG_STRIKES = int(os.getenv("WATCHDOG_STRIKES", "3")) + + +class CPUWatchdog: + def __init__(self): + self._task = None + self._strikes = 0 + self._last_cpu_time = None + self._last_wall_time = None + self._is_linux = sys.platform.startswith("linux") + + def _get_cpu_percent(self): + """Read CPU usage from /proc/self/stat. Returns 0-100 float.""" + if not self._is_linux: + return 0.0 + try: + with open("/proc/self/stat") as f: + fields = f.read().split() + # fields[13] = utime, fields[14] = stime (in clock ticks) + cpu_time = int(fields[13]) + int(fields[14]) + wall_time = time.monotonic() + ticks_per_sec = os.sysconf("SC_CLK_TCK") + + if self._last_cpu_time is not None: + cpu_delta = (cpu_time - self._last_cpu_time) / ticks_per_sec + wall_delta = wall_time - self._last_wall_time + if wall_delta > 0: + percent = (cpu_delta / wall_delta) * 100.0 + else: + percent = 0.0 + else: + percent = 0.0 + + self._last_cpu_time = cpu_time + self._last_wall_time = wall_time + return percent + except (FileNotFoundError, IndexError, ValueError, OSError): + return 0.0 + + async def _loop(self): + while True: + await asyncio.sleep(WATCHDOG_INTERVAL) + try: + cpu = self._get_cpu_percent() + if cpu > WATCHDOG_CPU_THRESHOLD: + self._strikes += 1 + logger.warning( + f"CPU watchdog: {cpu:.1f}% > {WATCHDOG_CPU_THRESHOLD}% " + f"(strike {self._strikes}/{WATCHDOG_STRIKES})" + ) + if self._strikes >= WATCHDOG_STRIKES: + logger.error( + f"CPU watchdog: {WATCHDOG_STRIKES} consecutive strikes, " + f"sending SIGTERM for clean restart" + ) + os.kill(os.getpid(), signal.SIGTERM) + return + else: + if self._strikes > 0: + logger.info(f"CPU watchdog: {cpu:.1f}% -- strikes reset") + self._strikes = 0 + except Exception as e: + logger.debug(f"CPU watchdog check failed: {e}") + + def start(self): + if not WATCHDOG_ENABLED: + logger.info("CPU watchdog disabled (set WATCHDOG_ENABLED=true to enable)") + return + if not self._is_linux: + logger.info("CPU watchdog skipped (Linux-only, use in Docker)") + return + logger.info( + f"CPU watchdog started: interval={WATCHDOG_INTERVAL}s, " + f"threshold={WATCHDOG_CPU_THRESHOLD}%, strikes={WATCHDOG_STRIKES}" + ) + self._task = asyncio.create_task(self._loop()) + + def stop(self): + if self._task and not self._task.done(): + self._task.cancel() + logger.info("CPU watchdog stopped") + + +cpu_watchdog = CPUWatchdog() diff --git a/src/function_calling.py b/src/function_calling.py new file mode 100644 index 0000000..e9a45b8 --- /dev/null +++ b/src/function_calling.py @@ -0,0 +1,145 @@ +"""Simulate OpenAI function calling via system prompt injection and response parsing.""" + +import json +import logging +import re +from uuid import uuid4 + +from src.models import Message, ToolCall, FunctionCall + +logger = logging.getLogger(__name__) + +_TOOL_CALL_FORMAT = """IMPORTANT: When you want to call a function, respond with ONLY a code block using the tool_calls language tag: + +```tool_calls +[ + {"name": "function_name", "arguments": {"param1": "value1"}} +] +``` + +You can call multiple functions in one response. Do not include any text outside the code block when calling functions.""" + + +def build_tools_system_prompt(tools: list, tool_choice=None) -> str: + if not tools and (tool_choice is None or tool_choice == "none"): + return "" + + if tool_choice == "none": + return "" + + parts = ["# Available Functions\n"] + + for tool in tools: + func = tool.get("function", {}) + name = func.get("name", "unknown") + description = func.get("description", "No description") + parameters = func.get("parameters", {}) + parts.append(f"## {name}\n{description}\nParameters: {json.dumps(parameters)}\n") + + if isinstance(tool_choice, dict): + forced_name = tool_choice.get("function", {}).get("name", "unknown") + parts.append(f"\nYou MUST call function {forced_name}.\n") + elif tool_choice == "required": + parts.append("\nYou MUST call at least one function.\n") + else: + parts.append("\nYou MAY call functions if helpful.\n") + + parts.append(_TOOL_CALL_FORMAT) + + return "\n".join(parts) + + +def parse_tool_calls(response_text: str) -> tuple: + # Primary: fenced tool_calls block + pattern = r"```tool_calls\s*\n(.*?)```" + match = re.search(pattern, response_text, re.DOTALL) + + if match: + try: + calls = json.loads(match.group(1).strip()) + remaining = response_text[:match.start()] + response_text[match.end():] + remaining = remaining.strip() + return (calls, remaining) + except json.JSONDecodeError: + logger.warning("Found tool_calls block but failed to parse JSON") + + # Fallback: bare JSON array starting with [{"name": + bare_pattern = r'(\[\s*\{\s*"name"\s*:.*\])' + bare_match = re.search(bare_pattern, response_text, re.DOTALL) + + if bare_match: + try: + calls = json.loads(bare_match.group(1)) + remaining = response_text[:bare_match.start()] + response_text[bare_match.end():] + remaining = remaining.strip() + return (calls, remaining) + except json.JSONDecodeError: + logger.warning("Found bare JSON array but failed to parse") + + return ([], response_text) + + +def format_tool_calls(parsed_calls: list) -> list: + result = [] + for call in parsed_calls: + name = call.get("name", "") + arguments = call.get("arguments", {}) + result.append(ToolCall( + id=f"call_{uuid4().hex[:24]}", + type="function", + function=FunctionCall( + name=name, + arguments=json.dumps(arguments), + ), + )) + return result + + +def convert_tool_messages(messages: list) -> list: + converted = [] + for msg in messages: + # Handle both Message objects and dicts + if isinstance(msg, Message): + role = msg.role + content = msg.content + tool_calls = msg.tool_calls + tool_call_id = msg.tool_call_id + name = msg.name + else: + role = msg.get("role", "") + content = msg.get("content") + tool_calls = msg.get("tool_calls") + tool_call_id = msg.get("tool_call_id") + name = msg.get("name") + + if role == "assistant" and tool_calls: + parts = [] + if content: + parts.append(content) + for tc in tool_calls: + if hasattr(tc, "function"): + fn_name = tc.function.name + fn_args = tc.function.arguments + else: + func = tc.get("function", {}) + fn_name = func.get("name", "unknown") + fn_args = func.get("arguments", "{}") + if isinstance(fn_args, str): + try: + fn_args = json.loads(fn_args) + except json.JSONDecodeError: + pass + args_str = json.dumps(fn_args) if isinstance(fn_args, dict) else fn_args + parts.append(f"[Called {fn_name} with arguments: {args_str}]") + converted.append(Message(role="assistant", content="\n".join(parts))) + + elif role == "tool": + tid = tool_call_id or "unknown" + tname = name or "unknown" + tcontent = content or "" + converted.append(Message(role="user", content=f"[Result of {tname} ({tid}): {tcontent}]")) + + else: + converted.append(msg) + + return converted diff --git a/src/main.py b/src/main.py index 2568e80..b6c3246 100644 --- a/src/main.py +++ b/src/main.py @@ -41,7 +41,14 @@ AnthropicUsage, ) from src.claude_cli import ClaudeCodeCLI -from src.message_adapter import MessageAdapter +from src.message_adapter import MessageAdapter, JsonFenceStripper +from src.function_calling import ( + build_tools_system_prompt, + parse_tool_calls, + format_tool_calls, + convert_tool_messages, +) +from src.cpu_watchdog import cpu_watchdog from src.auth import verify_api_key, security, validate_claude_code_auth, get_claude_code_auth_info from src.parameter_validator import ParameterValidator, CompatibilityReporter from src.session_manager import session_manager @@ -223,8 +230,12 @@ async def cost_cleanup_loop(): cost_cleanup_task = asyncio.get_running_loop().create_task(cost_cleanup_loop()) + # Start CPU watchdog (Linux/Docker only) + cpu_watchdog.start() + yield + cpu_watchdog.stop() cost_cleanup_task.cancel() # Cleanup on shutdown @@ -486,6 +497,10 @@ async def generate_streaming_response( request.messages, request.session_id ) + # Convert tool role messages for Claude compatibility + if request.tools: + all_messages = convert_tool_messages(all_messages) + # Convert messages to prompt prompt, system_prompt = MessageAdapter.messages_to_prompt(all_messages) @@ -498,17 +513,34 @@ async def generate_streaming_response( system_prompt = sampling_instructions logger.debug(f"Added sampling instructions: {sampling_instructions}") + # Function calling: inject tool definitions into system prompt + has_tools = request.tools and len(request.tools) > 0 + if has_tools: + tools_dicts = [t.model_dump() for t in request.tools] + tools_prompt = build_tools_system_prompt(tools_dicts, request.tool_choice) + if tools_prompt: + if system_prompt: + system_prompt = f"{system_prompt}\n\n{tools_prompt}" + else: + system_prompt = tools_prompt + logger.info(f"Function calling (streaming): injected {len(request.tools)} tool definitions") + # Check for JSON mode - json_mode = request.response_format and request.response_format.type == "json_object" + json_mode = request.response_format and request.response_format.type in ("json_object", "json_schema") if json_mode: - # Prepend JSON instruction to system prompt - if system_prompt: - system_prompt = f"{MessageAdapter.JSON_MODE_INSTRUCTION}\n\n{system_prompt}" + if request.response_format.type == "json_schema" and request.response_format.json_schema: + schema = request.response_format.json_schema + schema_json = json.dumps(schema.schema_ or {}, indent=2) + schema_instructions = MessageAdapter.JSON_SCHEMA_TEMPLATE.format(schema_json=schema_json) + prompt = f"{schema_instructions}\n\n{prompt}" + logger.info(f"JSON schema mode (streaming): injected schema into prompt") else: - system_prompt = MessageAdapter.JSON_MODE_INSTRUCTION - # Also append to user prompt to reinforce JSON requirement - prompt = prompt + MessageAdapter.JSON_PROMPT_SUFFIX - logger.info("JSON mode enabled (streaming) - instruction added to system and user prompt") + if system_prompt: + system_prompt = f"{MessageAdapter.JSON_MODE_INSTRUCTION}\n\n{system_prompt}" + else: + system_prompt = MessageAdapter.JSON_MODE_INSTRUCTION + prompt = prompt + MessageAdapter.JSON_PROMPT_SUFFIX + logger.info("JSON mode enabled (streaming) - instruction added to system and user prompt") # Filter content for unsupported features prompt = MessageAdapter.filter_content(prompt) @@ -522,6 +554,8 @@ async def generate_streaming_response( role_sent = False # Track if we've sent the initial role chunk content_sent = False # Track if we've sent any content json_mode_buffer = [] # Buffer for JSON mode - accumulate all content + tool_call_buffer = [] # Buffer when tools are defined - parse at end + fence_stripper = JsonFenceStripper() if json_mode else None async for chunk in claude_cli.run_completion( **_run_completion_kwargs(claude_options, prompt, system_prompt, stream=True), @@ -573,49 +607,106 @@ async def generate_streaming_response( filtered_text = MessageAdapter.filter_content(raw_text) if filtered_text and not filtered_text.isspace(): - if json_mode: - # In JSON mode, buffer content for later processing + if has_tools: + # Buffer when tools defined -- parse tool_calls at end + tool_call_buffer.append(filtered_text) + elif json_mode and fence_stripper: + # Stream through fence stripper + stripped = fence_stripper.process_delta(filtered_text) + if stripped: + stream_chunk = ChatCompletionStreamResponse( + id=request_id, + model=request.model, + choices=[StreamChoice(index=0, delta={"content": stripped}, finish_reason=None)], + ) + yield f"data: {stream_chunk.model_dump_json()}\n\n" + content_sent = True + elif json_mode: json_mode_buffer.append(filtered_text) else: - # Create streaming chunk stream_chunk = ChatCompletionStreamResponse( id=request_id, model=request.model, - choices=[ - StreamChoice( - index=0, - delta={"content": filtered_text}, - finish_reason=None, - ) - ], + choices=[StreamChoice(index=0, delta={"content": filtered_text}, finish_reason=None)], ) - yield f"data: {stream_chunk.model_dump_json()}\n\n" content_sent = True elif isinstance(content, str): - # Filter out tool usage and thinking blocks filtered_content = MessageAdapter.filter_content(content) if filtered_content and not filtered_content.isspace(): - if json_mode: - # In JSON mode, buffer content for later processing + if has_tools: + tool_call_buffer.append(filtered_content) + elif json_mode and fence_stripper: + stripped = fence_stripper.process_delta(filtered_content) + if stripped: + stream_chunk = ChatCompletionStreamResponse( + id=request_id, + model=request.model, + choices=[StreamChoice(index=0, delta={"content": stripped}, finish_reason=None)], + ) + yield f"data: {stream_chunk.model_dump_json()}\n\n" + content_sent = True + elif json_mode: json_mode_buffer.append(filtered_content) else: - # Create streaming chunk stream_chunk = ChatCompletionStreamResponse( id=request_id, model=request.model, - choices=[ - StreamChoice( - index=0, delta={"content": filtered_content}, finish_reason=None - ) - ], + choices=[StreamChoice(index=0, delta={"content": filtered_content}, finish_reason=None)], ) - yield f"data: {stream_chunk.model_dump_json()}\n\n" content_sent = True + # Flush fence stripper if used + if json_mode and fence_stripper: + remaining = fence_stripper.flush() + if remaining: + if not role_sent: + initial_chunk = ChatCompletionStreamResponse( + id=request_id, model=request.model, + choices=[StreamChoice(index=0, delta={"role": "assistant", "content": ""}, finish_reason=None)], + ) + yield f"data: {initial_chunk.model_dump_json()}\n\n" + role_sent = True + flush_chunk = ChatCompletionStreamResponse( + id=request_id, model=request.model, + choices=[StreamChoice(index=0, delta={"content": remaining}, finish_reason=None)], + ) + yield f"data: {flush_chunk.model_dump_json()}\n\n" + content_sent = True + + # Handle tool call buffer: parse and emit tool_calls + if has_tools and tool_call_buffer: + combined = "".join(tool_call_buffer) + parsed_calls, remaining_text = parse_tool_calls(combined) + if not role_sent: + initial_chunk = ChatCompletionStreamResponse( + id=request_id, model=request.model, + choices=[StreamChoice(index=0, delta={"role": "assistant", "content": ""}, finish_reason=None)], + ) + yield f"data: {initial_chunk.model_dump_json()}\n\n" + role_sent = True + if parsed_calls: + formatted = format_tool_calls(parsed_calls) + tc_delta = {"tool_calls": [tc.model_dump() for tc in formatted]} + if remaining_text.strip(): + tc_delta["content"] = remaining_text.strip() + tc_chunk = ChatCompletionStreamResponse( + id=request_id, model=request.model, + choices=[StreamChoice(index=0, delta=tc_delta, finish_reason=None)], + ) + yield f"data: {tc_chunk.model_dump_json()}\n\n" + content_sent = True + elif combined.strip(): + text_chunk = ChatCompletionStreamResponse( + id=request_id, model=request.model, + choices=[StreamChoice(index=0, delta={"content": combined}, finish_reason=None)], + ) + yield f"data: {text_chunk.model_dump_json()}\n\n" + content_sent = True + # Handle JSON mode: emit accumulated content as single JSON-formatted chunk if json_mode and json_mode_buffer: # Send role chunk first if not sent @@ -809,6 +900,10 @@ async def chat_completions( f"Chat completion: session_id={actual_session_id}, total_messages={len(all_messages)}" ) + # Convert tool role messages for Claude compatibility + if request_body.tools: + all_messages = convert_tool_messages(all_messages) + # Convert messages to prompt prompt, system_prompt = MessageAdapter.messages_to_prompt(all_messages) @@ -821,20 +916,49 @@ async def chat_completions( system_prompt = sampling_instructions logger.debug(f"Added sampling instructions: {sampling_instructions}") + # Function calling: inject tool definitions into system prompt + has_tools = request_body.tools and len(request_body.tools) > 0 + if has_tools: + tools_dicts = [t.model_dump() for t in request_body.tools] + tools_prompt = build_tools_system_prompt(tools_dicts, request_body.tool_choice) + if tools_prompt: + if system_prompt: + system_prompt = f"{system_prompt}\n\n{tools_prompt}" + else: + system_prompt = tools_prompt + logger.info(f"Function calling: injected {len(request_body.tools)} tool definitions") + # Check for JSON mode json_mode = ( request_body.response_format - and request_body.response_format.type == "json_object" + and request_body.response_format.type in ("json_object", "json_schema") ) if json_mode: - # Prepend JSON instruction to system prompt - if system_prompt: - system_prompt = f"{MessageAdapter.JSON_MODE_INSTRUCTION}\n\n{system_prompt}" + if request_body.response_format.type == "json_schema" and request_body.response_format.json_schema: + # JSON schema mode: inject schema into prompt (not system_prompt) + schema = request_body.response_format.json_schema + schema_json = json.dumps(schema.schema_ or {}, indent=2) + schema_instructions = ( + "You MUST respond with valid JSON that strictly conforms to the following JSON Schema.\n" + "Do not wrap the JSON in markdown code fences.\n" + "Do not include any text before or after the JSON.\n" + "RULES:\n" + "- Include ALL required properties from the schema, even if empty or default\n" + "- Use the EXACT property names from the schema\n" + "- Match the EXACT types specified (number not string, etc.)\n" + "- Do not add properties not in the schema\n\n" + f"JSON Schema:\n{schema_json}" + ) + prompt = f"{schema_instructions}\n\n{prompt}" + logger.info(f"JSON schema mode: injected schema ({len(schema_json)} chars) into prompt") else: - system_prompt = MessageAdapter.JSON_MODE_INSTRUCTION - # Also append to user prompt to reinforce JSON requirement - prompt = prompt + MessageAdapter.JSON_PROMPT_SUFFIX - logger.info("JSON mode enabled - instruction added to system and user prompt") + # Basic JSON object mode + if system_prompt: + system_prompt = f"{MessageAdapter.JSON_MODE_INSTRUCTION}\n\n{system_prompt}" + else: + system_prompt = MessageAdapter.JSON_MODE_INSTRUCTION + prompt = prompt + MessageAdapter.JSON_PROMPT_SUFFIX + logger.info("JSON mode enabled - instruction added to system and user prompt") # Filter content prompt = MessageAdapter.filter_content(prompt) @@ -880,14 +1004,29 @@ async def chat_completions( logger.debug(f"Extracted JSON preview: {assistant_content[:200]}") log_json_structure(assistant_content, logger) + # Parse function calls from response if tools were provided + tool_calls_list = None + finish_reason = "stop" + if has_tools: + parsed_calls, remaining_text = parse_tool_calls(assistant_content) + if parsed_calls: + tool_calls_list = format_tool_calls(parsed_calls) + assistant_content = remaining_text.strip() if remaining_text.strip() else None + finish_reason = "tool_calls" + logger.info(f"Function calling: parsed {len(parsed_calls)} tool call(s)") + # Add assistant response to session if using session mode if actual_session_id: - assistant_message = Message(role="assistant", content=assistant_content) + assistant_message = Message( + role="assistant", + content=assistant_content, + tool_calls=tool_calls_list, + ) session_manager.add_assistant_response(actual_session_id, assistant_message) # Estimate tokens (rough approximation) prompt_tokens = MessageAdapter.estimate_tokens(prompt) - completion_tokens = MessageAdapter.estimate_tokens(assistant_content) + completion_tokens = MessageAdapter.estimate_tokens(assistant_content or "") await cost_tracker.record_usage( session_id=actual_session_id or request_id, @@ -899,14 +1038,19 @@ async def chat_completions( ) # Create response + response_message = Message( + role="assistant", + content=assistant_content, + tool_calls=tool_calls_list, + ) response = ChatCompletionResponse( id=request_id, model=request_body.model, choices=[ Choice( index=0, - message=Message(role="assistant", content=assistant_content), - finish_reason="stop", + message=response_message, + finish_reason=finish_reason, ) ], usage=Usage( diff --git a/src/message_adapter.py b/src/message_adapter.py index 1603ea0..d18ca86 100644 --- a/src/message_adapter.py +++ b/src/message_adapter.py @@ -19,6 +19,70 @@ class JsonExtractionResult: preamble_found: Optional[str] = None +class JsonFenceStripper: + """Strips markdown ```json fences from streaming chunks in real-time.""" + + _FENCES = ["```json\n", "```json\r\n", "```\n", "```\r\n"] + _MAX_FENCE_LEN = 10 # longest fence prefix to buffer + _CLOSE = "```" + + def __init__(self): + self._opening_buf = "" + self._opening_stripped = False + self._holdback = "" + + def process_delta(self, chunk: str) -> str: + if not chunk: + return "" + + # Phase 1: detect and strip opening fence + if not self._opening_stripped: + self._opening_buf += chunk + if len(self._opening_buf) < self._MAX_FENCE_LEN: + # Still accumulating -- check if it could be a fence prefix + for fence in self._FENCES: + fence_str = fence + if fence_str.startswith(self._opening_buf): + return "" # could still match, hold back + # No fence can match, release buffer + self._opening_stripped = True + result = self._opening_buf + self._opening_buf = "" + return self._apply_holdback(result) + else: + # Buffer full -- check for fence match + self._opening_stripped = True + for fence in self._FENCES: + fence_str = fence + if self._opening_buf.startswith(fence_str): + remainder = self._opening_buf[len(fence_str):] + self._opening_buf = "" + return self._apply_holdback(remainder) + # No match, release everything + result = self._opening_buf + self._opening_buf = "" + return self._apply_holdback(result) + + return self._apply_holdback(chunk) + + def _apply_holdback(self, text: str) -> str: + combined = self._holdback + text + if len(combined) <= len(self._CLOSE): + self._holdback = combined + return "" + self._holdback = combined[-len(self._CLOSE):] + return combined[:-len(self._CLOSE)] + + def flush(self) -> str: + result = self._holdback + self._holdback = "" + # Strip closing fence if present + result = result.rstrip() + if result.endswith("```"): + result = result[:-3].rstrip() + return result + + class MessageAdapter: """Converts between OpenAI message format and Claude Code prompts.""" @@ -44,6 +108,18 @@ class MessageAdapter: "- No markdown, no code fences, no explanation" ) + JSON_SCHEMA_TEMPLATE = ( + "You MUST respond with valid JSON that strictly conforms to the following JSON Schema.\n" + "Do not wrap the JSON in markdown code fences.\n" + "Do not include any text before or after the JSON.\n" + "RULES:\n" + "- Include ALL required properties from the schema, even if empty or default\n" + "- Use the EXACT property names from the schema\n" + "- Match the EXACT types specified (number not string, etc.)\n" + "- Do not add properties not in the schema\n\n" + "JSON Schema:\n{schema_json}" + ) + # Common preambles that Claude may add before JSON output COMMON_PREAMBLES = [ "Here's the JSON:", @@ -495,13 +571,13 @@ def messages_to_prompt(messages: List[Message]) -> tuple[str, Optional[str]]: conversation_parts = [] for message in messages: + content = message.content or "" if message.role == "system": - # Use the last system message as the system prompt - system_prompt = message.content + system_prompt = content elif message.role == "user": - conversation_parts.append(f"Human: {message.content}") + conversation_parts.append(f"Human: {content}") elif message.role == "assistant": - conversation_parts.append(f"Assistant: {message.content}") + conversation_parts.append(f"Assistant: {content}") # Join conversation parts prompt = "\n\n".join(conversation_parts) diff --git a/src/models.py b/src/models.py index b513f2e..35c5150 100644 --- a/src/models.py +++ b/src/models.py @@ -22,10 +22,34 @@ class ContentPart(BaseModel): text: str +class FunctionCall(BaseModel): + name: str + arguments: str + + +class ToolCall(BaseModel): + id: str + type: Literal["function"] = "function" + function: FunctionCall + + +class FunctionDefinition(BaseModel): + name: str + description: Optional[str] = None + parameters: Optional[Dict[str, Any]] = None + + +class ToolDefinition(BaseModel): + type: Literal["function"] = "function" + function: FunctionDefinition + + class Message(BaseModel): - role: Literal["system", "user", "assistant"] - content: Union[str, List[ContentPart]] + role: Literal["system", "user", "assistant", "tool"] + content: Optional[Union[str, List[ContentPart]]] = None name: Optional[str] = None + tool_calls: Optional[List[ToolCall]] = None + tool_call_id: Optional[str] = None @model_validator(mode="after") def normalize_content(self): @@ -53,13 +77,20 @@ class StreamOptions(BaseModel): ) -class ResponseFormat(BaseModel): - """OpenAI-compatible response format specification.""" +class JsonSchema(BaseModel): + name: str = "" + description: Optional[str] = None + schema_: Optional[Dict[str, Any]] = Field(default=None, alias="schema") + strict: Optional[bool] = None + model_config = {"populate_by_name": True} - type: Literal["text", "json_object"] = Field( + +class ResponseFormat(BaseModel): + type: Literal["text", "json_object", "json_schema"] = Field( default="text", - description="Response format type - 'text' for regular text, 'json_object' for JSON mode", + description="Response format type", ) + json_schema: Optional[JsonSchema] = None class ChatCompletionRequest(BaseModel): @@ -92,6 +123,14 @@ class ChatCompletionRequest(BaseModel): default=None, description="Response format - use {'type': 'json_object'} for JSON mode", ) + tools: Optional[List[ToolDefinition]] = Field( + default=None, + description="List of tools the model may call (OpenAI function calling format)", + ) + tool_choice: Optional[Union[str, Dict[str, Any]]] = Field( + default=None, + description="Controls which function is called: 'none', 'auto', 'required', or specific function", + ) @field_validator("n") @classmethod @@ -215,7 +254,7 @@ def to_claude_options(self) -> Dict[str, Any]: class Choice(BaseModel): index: int message: Message - finish_reason: Optional[Literal["stop", "length", "content_filter", "null"]] = None + finish_reason: Optional[Literal["stop", "length", "content_filter", "tool_calls", "null"]] = None class Usage(BaseModel): @@ -237,7 +276,7 @@ class ChatCompletionResponse(BaseModel): class StreamChoice(BaseModel): index: int delta: Dict[str, Any] - finish_reason: Optional[Literal["stop", "length", "content_filter", "null"]] = None + finish_reason: Optional[Literal["stop", "length", "content_filter", "tool_calls", "null"]] = None class ChatCompletionStreamResponse(BaseModel): diff --git a/tests/test_cpu_watchdog_unit.py b/tests/test_cpu_watchdog_unit.py new file mode 100644 index 0000000..39dab11 --- /dev/null +++ b/tests/test_cpu_watchdog_unit.py @@ -0,0 +1,41 @@ +"""Tests for CPU watchdog module.""" + +import pytest +from unittest.mock import patch +from src.cpu_watchdog import CPUWatchdog + + +class TestCPUWatchdog: + def test_init_defaults(self): + wd = CPUWatchdog() + assert wd._task is None + assert wd._strikes == 0 + assert wd._last_cpu_time is None + + def test_get_cpu_percent_non_linux(self): + wd = CPUWatchdog() + wd._is_linux = False + assert wd._get_cpu_percent() == 0.0 + + def test_get_cpu_percent_first_call_returns_zero(self): + wd = CPUWatchdog() + wd._is_linux = True + with patch("builtins.open", side_effect=FileNotFoundError): + assert wd._get_cpu_percent() == 0.0 + + def test_start_disabled(self): + wd = CPUWatchdog() + with patch("src.cpu_watchdog.WATCHDOG_ENABLED", False): + wd.start() + assert wd._task is None + + def test_start_non_linux(self): + wd = CPUWatchdog() + wd._is_linux = False + with patch("src.cpu_watchdog.WATCHDOG_ENABLED", True): + wd.start() + assert wd._task is None + + def test_stop_no_task(self): + wd = CPUWatchdog() + wd.stop() # should not raise diff --git a/tests/test_fence_stripper_unit.py b/tests/test_fence_stripper_unit.py new file mode 100644 index 0000000..962ed54 --- /dev/null +++ b/tests/test_fence_stripper_unit.py @@ -0,0 +1,55 @@ +"""Tests for JsonFenceStripper streaming fence removal.""" + +import pytest +from src.message_adapter import JsonFenceStripper + + +class TestJsonFenceStripper: + def test_no_fences(self): + s = JsonFenceStripper() + result = s.process_delta('{"key": "value"}') + result += s.flush() + assert '"key"' in result + assert '"value"' in result + + def test_strips_json_fence(self): + s = JsonFenceStripper() + chunks = ['```json\n', '{"key": "val', 'ue"}', '\n```'] + output = "" + for c in chunks: + output += s.process_delta(c) + output += s.flush() + assert "```" not in output + assert '"key"' in output + + def test_strips_bare_fence(self): + s = JsonFenceStripper() + chunks = ['```\n', '{"a": 1}', '\n```'] + output = "" + for c in chunks: + output += s.process_delta(c) + output += s.flush() + assert "```" not in output + assert '"a"' in output + + def test_no_fence_passes_through(self): + s = JsonFenceStripper() + chunks = ['{"hello":', ' "world"}'] + output = "" + for c in chunks: + output += s.process_delta(c) + output += s.flush() + assert "hello" in output + assert "world" in output + + def test_empty_chunks(self): + s = JsonFenceStripper() + assert s.process_delta("") == "" + assert s.flush() == "" + + def test_single_large_chunk(self): + s = JsonFenceStripper() + text = '```json\n{"data": [1, 2, 3]}\n```' + output = s.process_delta(text) + s.flush() + assert "```" not in output + assert '"data"' in output diff --git a/tests/test_function_calling_unit.py b/tests/test_function_calling_unit.py new file mode 100644 index 0000000..d3c25dc --- /dev/null +++ b/tests/test_function_calling_unit.py @@ -0,0 +1,174 @@ +"""Tests for function calling simulation.""" + +import json +import pytest +from src.function_calling import ( + build_tools_system_prompt, + parse_tool_calls, + format_tool_calls, + convert_tool_messages, +) +from src.models import Message, ToolCall, FunctionCall + + +SAMPLE_TOOLS = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "search", + "description": "Search the web", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + }, + }, + }, +] + + +class TestBuildToolsSystemPrompt: + def test_no_tools_returns_empty(self): + assert build_tools_system_prompt([], None) == "" + + def test_none_choice_returns_empty(self): + assert build_tools_system_prompt(SAMPLE_TOOLS, "none") == "" + + def test_auto_choice_includes_may_call(self): + result = build_tools_system_prompt(SAMPLE_TOOLS, "auto") + assert "MAY call functions" in result + assert "get_weather" in result + assert "search" in result + + def test_required_choice_includes_must_call(self): + result = build_tools_system_prompt(SAMPLE_TOOLS, "required") + assert "MUST call at least one function" in result + + def test_specific_function_choice(self): + choice = {"type": "function", "function": {"name": "get_weather"}} + result = build_tools_system_prompt(SAMPLE_TOOLS, choice) + assert "MUST call function get_weather" in result + + def test_includes_tool_call_format(self): + result = build_tools_system_prompt(SAMPLE_TOOLS, "auto") + assert "```tool_calls" in result + + def test_includes_parameters(self): + result = build_tools_system_prompt(SAMPLE_TOOLS, "auto") + assert "location" in result + assert "query" in result + + def test_default_choice_is_auto(self): + result = build_tools_system_prompt(SAMPLE_TOOLS) + assert "MAY call functions" in result + + +class TestParseToolCalls: + def test_fenced_tool_calls(self): + text = 'Some text\n```tool_calls\n[{"name": "get_weather", "arguments": {"location": "NYC"}}]\n```\nMore text' + calls, remaining = parse_tool_calls(text) + assert len(calls) == 1 + assert calls[0]["name"] == "get_weather" + assert calls[0]["arguments"]["location"] == "NYC" + assert "Some text" in remaining + assert "More text" in remaining + + def test_multiple_tool_calls(self): + text = '```tool_calls\n[{"name": "get_weather", "arguments": {"location": "NYC"}}, {"name": "search", "arguments": {"query": "hello"}}]\n```' + calls, remaining = parse_tool_calls(text) + assert len(calls) == 2 + + def test_bare_json_array_fallback(self): + text = 'Here are the results:\n[{"name": "search", "arguments": {"query": "test"}}]' + calls, remaining = parse_tool_calls(text) + assert len(calls) == 1 + assert calls[0]["name"] == "search" + + def test_no_tool_calls(self): + text = "Just a regular response with no function calls." + calls, remaining = parse_tool_calls(text) + assert calls == [] + assert remaining == text + + def test_malformed_json_returns_empty(self): + text = '```tool_calls\nnot valid json\n```' + calls, remaining = parse_tool_calls(text) + assert calls == [] + + +class TestFormatToolCalls: + def test_basic_format(self): + parsed = [{"name": "get_weather", "arguments": {"location": "NYC"}}] + result = format_tool_calls(parsed) + assert len(result) == 1 + assert result[0].type == "function" + assert result[0].function.name == "get_weather" + assert result[0].id.startswith("call_") + assert json.loads(result[0].function.arguments) == {"location": "NYC"} + + def test_multiple_calls_get_unique_ids(self): + parsed = [ + {"name": "a", "arguments": {}}, + {"name": "b", "arguments": {}}, + ] + result = format_tool_calls(parsed) + assert result[0].id != result[1].id + + +class TestConvertToolMessages: + def test_assistant_with_tool_calls(self): + msg = Message( + role="assistant", + content="Let me check", + tool_calls=[ + ToolCall( + id="call_123", + type="function", + function=FunctionCall(name="get_weather", arguments='{"location": "NYC"}'), + ) + ], + ) + result = convert_tool_messages([msg]) + assert len(result) == 1 + assert result[0].role == "assistant" + assert "Called get_weather" in result[0].content + assert "Let me check" in result[0].content + + def test_tool_result_message(self): + msg = Message(role="tool", content="72F and sunny", name="get_weather", tool_call_id="call_123") + result = convert_tool_messages([msg]) + assert len(result) == 1 + assert result[0].role == "user" + assert "Result of get_weather" in result[0].content + + def test_regular_messages_pass_through(self): + msg = Message(role="user", content="Hello") + result = convert_tool_messages([msg]) + assert result[0] is msg + + def test_mixed_conversation(self): + messages = [ + Message(role="user", content="What's the weather?"), + Message( + role="assistant", + content=None, + tool_calls=[ToolCall(id="c1", type="function", function=FunctionCall(name="get_weather", arguments='{"location": "NYC"}'))], + ), + Message(role="tool", content="72F", name="get_weather", tool_call_id="c1"), + ] + result = convert_tool_messages(messages) + assert len(result) == 3 + assert result[0].role == "user" + assert result[1].role == "assistant" + assert result[2].role == "user" From 8c3ba9dca27eb7b4f0dcd3d25ebf74e15a43ef1f Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Thu, 2 Apr 2026 19:01:27 -0400 Subject: [PATCH 15/38] fix: address code review findings - Extract duplicated JSON schema instructions to MessageAdapter.JSON_SCHEMA_TEMPLATE - Remove no-op fence_str=fence assignments in JsonFenceStripper - Fix filter_content(None) to return "" instead of None (type safety) - Fix greedy bare JSON regex in parse_tool_calls (use json.loads validation) - Add log when tools + json_mode both active in streaming - Add precise return type annotation to parse_tool_calls - Add tests: json_schema model, dict message conversion, nested array parsing --- .hypothesis/constants/0b9f5d19f0cc2503 | 4 +++ .hypothesis/constants/1592681e34a69c28 | 4 +++ .hypothesis/constants/1ac918ed1f76c9f4 | 4 +++ .hypothesis/constants/1d2c6cf78e4a0d3b | 4 +++ .hypothesis/constants/24fcd0f4bf56b6a5 | 4 +++ .hypothesis/constants/2be0ce1ce4912b41 | 4 +++ .hypothesis/constants/33a6c4f86be05bf0 | 4 +++ .hypothesis/constants/3bbed57e7f5f907a | 4 +++ .hypothesis/constants/3bedde4e911abb67 | 4 +++ .hypothesis/constants/416f667f337eef4d | 4 +++ .hypothesis/constants/49266abea451322c | 4 +++ .hypothesis/constants/4bfa246f2ad136a7 | 4 +++ .hypothesis/constants/4ff5447358cce36d | 4 +++ .hypothesis/constants/5a015d1988280896 | 4 +++ .hypothesis/constants/5eace2102a943108 | 4 +++ .hypothesis/constants/5ec5250a39fbf461 | 4 +++ .hypothesis/constants/5ecb8d27c15539fb | 4 +++ .hypothesis/constants/5f1ff972bb16d351 | 4 +++ .hypothesis/constants/5fa7e22095c251de | 4 +++ .hypothesis/constants/62961dda076a1c18 | 4 +++ .hypothesis/constants/6720b331c8de2f4d | 4 +++ .hypothesis/constants/6945d5fe75d7baf9 | 4 +++ .hypothesis/constants/6a1bdddafd3867b0 | 4 +++ .hypothesis/constants/6c388abca123fca7 | 4 +++ .hypothesis/constants/6f4af6e3fb4bf935 | 4 +++ .hypothesis/constants/79a494eefa2125eb | 4 +++ .hypothesis/constants/7c2b91b3ea4d5bae | 4 +++ .hypothesis/constants/7cbb728ba70d01ef | 4 +++ .hypothesis/constants/8147e68ddedfd20b | 4 +++ .hypothesis/constants/8c6b3f1674b9e0fe | 4 +++ .hypothesis/constants/92d90c488a56ada0 | 4 +++ .hypothesis/constants/9adb793441356481 | 4 +++ .hypothesis/constants/a282b0de12e1165d | 4 +++ .hypothesis/constants/a560162a0935d261 | 4 +++ .hypothesis/constants/addbf4cc0fd2c0d3 | 4 +++ .hypothesis/constants/b04074d551450985 | 4 +++ .hypothesis/constants/b557a9a709d4c7cf | 4 +++ .hypothesis/constants/ba3ef7c1e31eb53a | 4 +++ .hypothesis/constants/bd1bff39ca7e3f9f | 4 +++ .hypothesis/constants/c2ebc0a232bcf5ab | 4 +++ .hypothesis/constants/c48321436c435109 | 4 +++ .hypothesis/constants/c6b66dd364db4aea | 4 +++ .hypothesis/constants/cc377c555d1180c1 | 4 +++ .hypothesis/constants/cd8780436271eddb | 4 +++ .hypothesis/constants/cfb85dbb9b5d85a6 | 4 +++ .hypothesis/constants/d14c45ee4f738a0e | 4 +++ .hypothesis/constants/d434e96105f62824 | 4 +++ .hypothesis/constants/d834e79418fe5fa5 | 4 +++ .hypothesis/constants/d84afc418365a945 | 4 +++ .hypothesis/constants/db4f54cef59f98f2 | 4 +++ .hypothesis/constants/dea42edc03d45162 | 4 +++ .hypothesis/constants/eb715738993787bc | 4 +++ .hypothesis/constants/f070aebf9a1fa192 | 4 +++ .hypothesis/constants/f0942b966cd1a673 | 4 +++ .hypothesis/constants/f102fa85cdaff8e2 | 4 +++ .hypothesis/constants/f421bd7fea970ca8 | 4 +++ .hypothesis/constants/fb8091c3914026d9 | 4 +++ .hypothesis/constants/fbd667538a3b64b4 | 4 +++ .hypothesis/constants/fe53ac5fa2ae2faf | 4 +++ .../unicode_data/14.0.0/charmap.json.gz | Bin 0 -> 21505 bytes .../unicode_data/14.0.0/codec-utf-8.json.gz | Bin 0 -> 60 bytes src/function_calling.py | 27 +++++++++++------- src/main.py | 15 +++------- src/message_adapter.py | 10 +++---- tests/test_cpu_watchdog_unit.py | 14 +++++++++ tests/test_function_calling_unit.py | 27 ++++++++++++++++++ tests/test_json_format_unit.py | 27 ++++++++++++++++++ tests/test_message_adapter_unit.py | 2 +- 68 files changed, 329 insertions(+), 29 deletions(-) create mode 100644 .hypothesis/constants/0b9f5d19f0cc2503 create mode 100644 .hypothesis/constants/1592681e34a69c28 create mode 100644 .hypothesis/constants/1ac918ed1f76c9f4 create mode 100644 .hypothesis/constants/1d2c6cf78e4a0d3b create mode 100644 .hypothesis/constants/24fcd0f4bf56b6a5 create mode 100644 .hypothesis/constants/2be0ce1ce4912b41 create mode 100644 .hypothesis/constants/33a6c4f86be05bf0 create mode 100644 .hypothesis/constants/3bbed57e7f5f907a create mode 100644 .hypothesis/constants/3bedde4e911abb67 create mode 100644 .hypothesis/constants/416f667f337eef4d create mode 100644 .hypothesis/constants/49266abea451322c create mode 100644 .hypothesis/constants/4bfa246f2ad136a7 create mode 100644 .hypothesis/constants/4ff5447358cce36d create mode 100644 .hypothesis/constants/5a015d1988280896 create mode 100644 .hypothesis/constants/5eace2102a943108 create mode 100644 .hypothesis/constants/5ec5250a39fbf461 create mode 100644 .hypothesis/constants/5ecb8d27c15539fb create mode 100644 .hypothesis/constants/5f1ff972bb16d351 create mode 100644 .hypothesis/constants/5fa7e22095c251de create mode 100644 .hypothesis/constants/62961dda076a1c18 create mode 100644 .hypothesis/constants/6720b331c8de2f4d create mode 100644 .hypothesis/constants/6945d5fe75d7baf9 create mode 100644 .hypothesis/constants/6a1bdddafd3867b0 create mode 100644 .hypothesis/constants/6c388abca123fca7 create mode 100644 .hypothesis/constants/6f4af6e3fb4bf935 create mode 100644 .hypothesis/constants/79a494eefa2125eb create mode 100644 .hypothesis/constants/7c2b91b3ea4d5bae create mode 100644 .hypothesis/constants/7cbb728ba70d01ef create mode 100644 .hypothesis/constants/8147e68ddedfd20b create mode 100644 .hypothesis/constants/8c6b3f1674b9e0fe create mode 100644 .hypothesis/constants/92d90c488a56ada0 create mode 100644 .hypothesis/constants/9adb793441356481 create mode 100644 .hypothesis/constants/a282b0de12e1165d create mode 100644 .hypothesis/constants/a560162a0935d261 create mode 100644 .hypothesis/constants/addbf4cc0fd2c0d3 create mode 100644 .hypothesis/constants/b04074d551450985 create mode 100644 .hypothesis/constants/b557a9a709d4c7cf create mode 100644 .hypothesis/constants/ba3ef7c1e31eb53a create mode 100644 .hypothesis/constants/bd1bff39ca7e3f9f create mode 100644 .hypothesis/constants/c2ebc0a232bcf5ab create mode 100644 .hypothesis/constants/c48321436c435109 create mode 100644 .hypothesis/constants/c6b66dd364db4aea create mode 100644 .hypothesis/constants/cc377c555d1180c1 create mode 100644 .hypothesis/constants/cd8780436271eddb create mode 100644 .hypothesis/constants/cfb85dbb9b5d85a6 create mode 100644 .hypothesis/constants/d14c45ee4f738a0e create mode 100644 .hypothesis/constants/d434e96105f62824 create mode 100644 .hypothesis/constants/d834e79418fe5fa5 create mode 100644 .hypothesis/constants/d84afc418365a945 create mode 100644 .hypothesis/constants/db4f54cef59f98f2 create mode 100644 .hypothesis/constants/dea42edc03d45162 create mode 100644 .hypothesis/constants/eb715738993787bc create mode 100644 .hypothesis/constants/f070aebf9a1fa192 create mode 100644 .hypothesis/constants/f0942b966cd1a673 create mode 100644 .hypothesis/constants/f102fa85cdaff8e2 create mode 100644 .hypothesis/constants/f421bd7fea970ca8 create mode 100644 .hypothesis/constants/fb8091c3914026d9 create mode 100644 .hypothesis/constants/fbd667538a3b64b4 create mode 100644 .hypothesis/constants/fe53ac5fa2ae2faf create mode 100644 .hypothesis/unicode_data/14.0.0/charmap.json.gz create mode 100644 .hypothesis/unicode_data/14.0.0/codec-utf-8.json.gz diff --git a/.hypothesis/constants/0b9f5d19f0cc2503 b/.hypothesis/constants/0b9f5d19f0cc2503 new file mode 100644 index 0000000..af274b2 --- /dev/null +++ b/.hypothesis/constants/0b9f5d19f0cc2503 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/models.py +# hypothesis_version: 6.151.4 + +[0.3, 0.5, 0.7, 0.9, 1.0, 1.5, 100, 200, 500, 4096, '-_.', 'Response format type', 'System prompt', 'after', 'assistant', 'chat.completion', 'command', 'content_filter', 'end_turn', 'function', 'json_object', 'json_schema', 'length', 'max_thinking_tokens', 'max_tokens', 'message', 'model', 'n', 'name', 'null', 'populate_by_name', 'schema', 'server_name', 'stop', 'stop_sequence', 'system', 'text', 'tool', 'tool_calls', 'tool_name', 'type', 'user'] \ No newline at end of file diff --git a/.hypothesis/constants/1592681e34a69c28 b/.hypothesis/constants/1592681e34a69c28 new file mode 100644 index 0000000..17f5116 --- /dev/null +++ b/.hypothesis/constants/1592681e34a69c28 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/message_adapter.py +# hypothesis_version: 6.151.4 + +[200, '"', '.*?', '.*?', 'Here is the JSON:', 'Here is the data:', 'Here is the output:', 'Here is the result:', 'Here is your JSON:', "Here's the JSON:", "Here's the data:", "Here's the output:", "Here's the response:", "Here's the result:", "Here's your JSON:", 'JSON response:', 'Output:', 'Response:', 'Result:', 'The JSON is:', '[', '[]', '\\', '\\n\\s*\\n\\s*\\n', ']', '```', 'assistant', 'brace_match', 'code_block', 'content', 'direct', 'extracted_length', 'failed', 'fallback', 'fallback_used', 'fallback_value', 'finish_reason', 'method', 'model', 'original_length', 'preamble_found', 'preamble_removed', 'role', 'stop', 'strict_mode', 'success', 'system', 'user', '{', '{[', '}', '}]'] \ No newline at end of file diff --git a/.hypothesis/constants/1ac918ed1f76c9f4 b/.hypothesis/constants/1ac918ed1f76c9f4 new file mode 100644 index 0000000..bf90daa --- /dev/null +++ b/.hypothesis/constants/1ac918ed1f76c9f4 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py +# hypothesis_version: 6.151.4 + +[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Disconnected', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'effort', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'json_schema', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'thinking', 'tool_calls', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/1d2c6cf78e4a0d3b b/.hypothesis/constants/1d2c6cf78e4a0d3b new file mode 100644 index 0000000..eb38c4a --- /dev/null +++ b/.hypothesis/constants/1d2c6cf78e4a0d3b @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/__init__.py +# hypothesis_version: 6.151.4 + +['2.4.1'] \ No newline at end of file diff --git a/.hypothesis/constants/24fcd0f4bf56b6a5 b/.hypothesis/constants/24fcd0f4bf56b6a5 new file mode 100644 index 0000000..7ef59bf --- /dev/null +++ b/.hypothesis/constants/24fcd0f4bf56b6a5 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/model_service.py +# hypothesis_version: 6.151.4 + +[10.0, 200, 401, 429, '2023-06-01', 'ANTHROPIC_API_KEY', 'anthropic-version', 'data', 'id', 'x-api-key'] \ No newline at end of file diff --git a/.hypothesis/constants/2be0ce1ce4912b41 b/.hypothesis/constants/2be0ce1ce4912b41 new file mode 100644 index 0000000..4ab6278 --- /dev/null +++ b/.hypothesis/constants/2be0ce1ce4912b41 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/constants.py +# hypothesis_version: 6.151.4 + +[0.01, 0.08, 0.1, 0.3, 0.5, 0.8, 1.0, 1.25, 1.5, 3.0, 3.75, 4.0, 5.0, 6.25, 15.0, 18.75, 25.0, 75.0, 100, 200, 8000, 8192, 32000, 64000, 128000, 200000, 600000, 'Agent', 'AskUserQuestion', 'Bash', 'BashOutput', 'CronCreate', 'CronDelete', 'CronList', 'DEFAULT_MODEL', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'ExitPlanMode', 'ExitWorktree', 'Glob', 'Grep', 'KillShell', 'NotebookEdit', 'Read', 'RemoteTrigger', 'SendMessage', 'Skill', 'SlashCommand', 'Task', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'TodoWrite', 'ToolSearch', 'WebFetch', 'WebSearch', 'Write', 'adaptive', 'cache_read', 'cache_write', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude_code', 'context_window', 'default_max_output', 'disabled', 'enabled', 'high', 'input', 'low', 'max', 'max_output_limit', 'medium', 'output', 'preset', 'text'] \ No newline at end of file diff --git a/.hypothesis/constants/33a6c4f86be05bf0 b/.hypothesis/constants/33a6c4f86be05bf0 new file mode 100644 index 0000000..d4abd27 --- /dev/null +++ b/.hypothesis/constants/33a6c4f86be05bf0 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py +# hypothesis_version: 6.151.4 + +[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'Not Connected', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'effort', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'thinking', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/3bbed57e7f5f907a b/.hypothesis/constants/3bbed57e7f5f907a new file mode 100644 index 0000000..ea9c0b9 --- /dev/null +++ b/.hypothesis/constants/3bbed57e7f5f907a @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/function_calling.py +# hypothesis_version: 6.151.4 + +['No description', 'arguments', 'assistant', 'content', 'description', 'function', 'id', 'name', 'none', 'parameters', 'required', 'role', 'tool', 'tool_call_id', 'tool_calls', 'type', 'unknown', 'user', '{}'] \ No newline at end of file diff --git a/.hypothesis/constants/3bedde4e911abb67 b/.hypothesis/constants/3bedde4e911abb67 new file mode 100644 index 0000000..59d8292 --- /dev/null +++ b/.hypothesis/constants/3bedde4e911abb67 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/retry.py +# hypothesis_version: 6.151.4 + +[0.25, 401, 429, 500, 529, 1000, 30000, 'connection', 'context', 'econnreset', 'epipe', 'overflow', 'timeout', 'too long'] \ No newline at end of file diff --git a/.hypothesis/constants/416f667f337eef4d b/.hypothesis/constants/416f667f337eef4d new file mode 100644 index 0000000..55bf8e2 --- /dev/null +++ b/.hypothesis/constants/416f667f337eef4d @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/tool_manager.py +# hypothesis_version: 6.151.4 + +['Agent', 'AskUserQuestion', 'Available choices', 'Bash', 'BashOutput', 'Command to run', 'Create a new file', 'CronCreate', 'CronDelete', 'CronList', 'Delete notebook cell', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'Execute git status', 'ExitPlanMode', 'ExitWorktree', 'Find TODO comments', 'Glob', 'Grep', 'ID of cell to edit', 'KillShell', 'List all tasks', 'Message content', 'New cell content', 'New status', 'NotebookEdit', 'Path to .ipynb file', 'Question to ask', 'Read', 'Read blog post', 'Read entire file', 'Read images and PDFs', 'RemoteTrigger', 'Rename a variable', 'Replacement text', 'Run npm install', 'Search query', 'SendMessage', 'Skill', 'SlashCommand', 'Stop a running task', 'Task', 'Task ID', 'Task ID to retrieve', 'Task ID to stop', 'Task description', 'Task subject', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'Text to replace', 'TodoWrite', 'ToolSearch', 'WebFetch', 'WebSearch', 'Write', 'agent', 'allowed_domains', 'bash_id', 'blocked_domains', 'branch', 'cell_id', 'cell_type', 'code or markdown', 'command', 'content', 'cronId', 'description', 'discovery', 'edit_mode', 'file', 'file_path', 'filter', 'git', 'glob', 'global_allowed', 'global_disallowed', 'interaction', 'isolation', 'limit', 'message', 'model', 'new_source', 'new_string', 'notebook_path', 'offset', 'old_string', 'options', 'output_mode', 'path', 'pattern', 'planning', 'productivity', 'prompt', 'query', 'question', 'replace_all', 'run_in_background', 'schedule', 'scheduling', 'session_configs', 'shell_id', 'status', 'subagent_type', 'subject', 'system', 'task', 'taskId', 'timeout', 'to', 'todos', 'tool_categories', 'total_tools', 'trigger', 'url', 'web'] \ No newline at end of file diff --git a/.hypothesis/constants/49266abea451322c b/.hypothesis/constants/49266abea451322c new file mode 100644 index 0000000..86ecf9b --- /dev/null +++ b/.hypothesis/constants/49266abea451322c @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/models.py +# hypothesis_version: 6.151.4 + +[0.3, 0.5, 0.7, 0.9, 1.0, 1.5, 100, 200, 500, 4096, '-_.', 'System prompt', 'after', 'assistant', 'chat.completion', 'command', 'content_filter', 'end_turn', 'json_object', 'length', 'max_thinking_tokens', 'max_tokens', 'message', 'model', 'n', 'name', 'null', 'server_name', 'stop', 'stop_sequence', 'system', 'text', 'tool_name', 'type', 'user'] \ No newline at end of file diff --git a/.hypothesis/constants/4bfa246f2ad136a7 b/.hypothesis/constants/4bfa246f2ad136a7 new file mode 100644 index 0000000..cc6d3ab --- /dev/null +++ b/.hypothesis/constants/4bfa246f2ad136a7 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/claude_cli.py +# hypothesis_version: 6.151.4 + +[0.0, 1000, 600000, 'Hello', '_', '__dict__', 'assistant', 'claude_code', 'completion_tokens', 'content', 'data', 'duration_ms', 'error_message', 'init', 'is_error', 'message', 'model', 'num_turns', 'preset', 'prompt_tokens', 'result', 'session_id', 'subtype', 'success', 'system', 'text', 'total_cost_usd', 'total_tokens', 'type'] \ No newline at end of file diff --git a/.hypothesis/constants/4ff5447358cce36d b/.hypothesis/constants/4ff5447358cce36d new file mode 100644 index 0000000..409771b --- /dev/null +++ b/.hypothesis/constants/4ff5447358cce36d @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/message_adapter.py +# hypothesis_version: 6.151.4 + +[200, '"', '.*?', '.*?', 'Here is the JSON:', 'Here is the data:', 'Here is the output:', 'Here is the result:', 'Here is your JSON:', "Here's the JSON:", "Here's the data:", "Here's the output:", "Here's the response:", "Here's the result:", "Here's your JSON:", 'JSON response:', 'Output:', 'Response:', 'Result:', 'The JSON is:', '[', '[]', '\\', '\\n\\s*\\n\\s*\\n', ']', '```', '```\n', '```\r\n', '```json\n', '```json\r\n', 'assistant', 'brace_match', 'code_block', 'content', 'direct', 'extracted_length', 'failed', 'fallback', 'fallback_used', 'fallback_value', 'finish_reason', 'method', 'model', 'original_length', 'preamble_found', 'preamble_removed', 'role', 'stop', 'strict_mode', 'success', 'system', 'user', '{', '{[', '}', '}]'] \ No newline at end of file diff --git a/.hypothesis/constants/5a015d1988280896 b/.hypothesis/constants/5a015d1988280896 new file mode 100644 index 0000000..ea9c0b9 --- /dev/null +++ b/.hypothesis/constants/5a015d1988280896 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/function_calling.py +# hypothesis_version: 6.151.4 + +['No description', 'arguments', 'assistant', 'content', 'description', 'function', 'id', 'name', 'none', 'parameters', 'required', 'role', 'tool', 'tool_call_id', 'tool_calls', 'type', 'unknown', 'user', '{}'] \ No newline at end of file diff --git a/.hypothesis/constants/5eace2102a943108 b/.hypothesis/constants/5eace2102a943108 new file mode 100644 index 0000000..3174e75 --- /dev/null +++ b/.hypothesis/constants/5eace2102a943108 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/__init__.py +# hypothesis_version: 6.151.4 + +['2.5.1'] \ No newline at end of file diff --git a/.hypothesis/constants/5ec5250a39fbf461 b/.hypothesis/constants/5ec5250a39fbf461 new file mode 100644 index 0000000..1ae5b0f --- /dev/null +++ b/.hypothesis/constants/5ec5250a39fbf461 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py +# hypothesis_version: 6.151.4 + +[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'Not Connected', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'effort', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'thinking', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/5ecb8d27c15539fb b/.hypothesis/constants/5ecb8d27c15539fb new file mode 100644 index 0000000..27b2349 --- /dev/null +++ b/.hypothesis/constants/5ecb8d27c15539fb @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/rate_limiter.py +# hypothesis_version: 6.151.4 + +[429, '/', '1', '10/minute', '15/minute', '2/minute', '30', '30/minute', 'RATE_LIMIT_ENABLED', 'Retry-After', 'auth', 'chat', 'code', 'debug', 'error', 'general', 'health', 'message', 'on', 'rate_limit_exceeded', 'retry_after', 'session', 'too_many_requests', 'true', 'type', 'yes'] \ No newline at end of file diff --git a/.hypothesis/constants/5f1ff972bb16d351 b/.hypothesis/constants/5f1ff972bb16d351 new file mode 100644 index 0000000..e194158 --- /dev/null +++ b/.hypothesis/constants/5f1ff972bb16d351 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/model_service.py +# hypothesis_version: 6.151.4 + +[10.0, 200, 401, 429, '2023-06-01', 'ANTHROPIC_API_KEY', 'anthropic', 'anthropic-version', 'api', 'auth_method', 'bedrock', 'claude_cli', 'count', 'current_count', 'data', 'fallback', 'id', 'initialized', 'last_refresh', 'message', 'model_count', 'models', 'source', 'success', 'vertex', 'x-api-key'] \ No newline at end of file diff --git a/.hypothesis/constants/5fa7e22095c251de b/.hypothesis/constants/5fa7e22095c251de new file mode 100644 index 0000000..b3a9add --- /dev/null +++ b/.hypothesis/constants/5fa7e22095c251de @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/__init__.py +# hypothesis_version: 6.151.4 + +['2.6.0'] \ No newline at end of file diff --git a/.hypothesis/constants/62961dda076a1c18 b/.hypothesis/constants/62961dda076a1c18 new file mode 100644 index 0000000..5db8079 --- /dev/null +++ b/.hypothesis/constants/62961dda076a1c18 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/model_service.py +# hypothesis_version: 6.151.4 + +[10.0, 200, 401, 429, '2023-06-01', 'ANTHROPIC_API_KEY', 'anthropic-version', 'api', 'count', 'current_count', 'data', 'fallback', 'id', 'initialized', 'last_refresh', 'message', 'model_count', 'models', 'source', 'success', 'x-api-key'] \ No newline at end of file diff --git a/.hypothesis/constants/6720b331c8de2f4d b/.hypothesis/constants/6720b331c8de2f4d new file mode 100644 index 0000000..a7c8a25 --- /dev/null +++ b/.hypothesis/constants/6720b331c8de2f4d @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/function_calling.py +# hypothesis_version: 6.151.4 + +['No description', '\\[\\s*\\{\\s*"name"\\s*:', ']', 'arguments', 'assistant', 'content', 'description', 'function', 'name', 'none', 'parameters', 'required', 'role', 'tool', 'tool_call_id', 'tool_calls', 'unknown', 'user', '{}'] \ No newline at end of file diff --git a/.hypothesis/constants/6945d5fe75d7baf9 b/.hypothesis/constants/6945d5fe75d7baf9 new file mode 100644 index 0000000..ba7699f --- /dev/null +++ b/.hypothesis/constants/6945d5fe75d7baf9 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/constants.py +# hypothesis_version: 6.151.4 + +[100, 200, 8000, 600000, 'Bash', 'BashOutput', 'DEFAULT_MODEL', 'Edit', 'Glob', 'Grep', 'KillShell', 'NotebookEdit', 'Read', 'Skill', 'SlashCommand', 'Task', 'TodoWrite', 'WebFetch', 'WebSearch', 'Write', 'claude_code', 'preset', 'text'] \ No newline at end of file diff --git a/.hypothesis/constants/6a1bdddafd3867b0 b/.hypothesis/constants/6a1bdddafd3867b0 new file mode 100644 index 0000000..bf90daa --- /dev/null +++ b/.hypothesis/constants/6a1bdddafd3867b0 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py +# hypothesis_version: 6.151.4 + +[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Disconnected', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'effort', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'json_schema', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'thinking', 'tool_calls', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/6c388abca123fca7 b/.hypothesis/constants/6c388abca123fca7 new file mode 100644 index 0000000..799af7c --- /dev/null +++ b/.hypothesis/constants/6c388abca123fca7 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/__init__.py +# hypothesis_version: 6.151.4 + +['2.4.0'] \ No newline at end of file diff --git a/.hypothesis/constants/6f4af6e3fb4bf935 b/.hypothesis/constants/6f4af6e3fb4bf935 new file mode 100644 index 0000000..5444ddc --- /dev/null +++ b/.hypothesis/constants/6f4af6e3fb4bf935 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/.venv/bin/pytest +# hypothesis_version: 6.151.4 + +['__main__'] \ No newline at end of file diff --git a/.hypothesis/constants/79a494eefa2125eb b/.hypothesis/constants/79a494eefa2125eb new file mode 100644 index 0000000..55bf8e2 --- /dev/null +++ b/.hypothesis/constants/79a494eefa2125eb @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/tool_manager.py +# hypothesis_version: 6.151.4 + +['Agent', 'AskUserQuestion', 'Available choices', 'Bash', 'BashOutput', 'Command to run', 'Create a new file', 'CronCreate', 'CronDelete', 'CronList', 'Delete notebook cell', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'Execute git status', 'ExitPlanMode', 'ExitWorktree', 'Find TODO comments', 'Glob', 'Grep', 'ID of cell to edit', 'KillShell', 'List all tasks', 'Message content', 'New cell content', 'New status', 'NotebookEdit', 'Path to .ipynb file', 'Question to ask', 'Read', 'Read blog post', 'Read entire file', 'Read images and PDFs', 'RemoteTrigger', 'Rename a variable', 'Replacement text', 'Run npm install', 'Search query', 'SendMessage', 'Skill', 'SlashCommand', 'Stop a running task', 'Task', 'Task ID', 'Task ID to retrieve', 'Task ID to stop', 'Task description', 'Task subject', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'Text to replace', 'TodoWrite', 'ToolSearch', 'WebFetch', 'WebSearch', 'Write', 'agent', 'allowed_domains', 'bash_id', 'blocked_domains', 'branch', 'cell_id', 'cell_type', 'code or markdown', 'command', 'content', 'cronId', 'description', 'discovery', 'edit_mode', 'file', 'file_path', 'filter', 'git', 'glob', 'global_allowed', 'global_disallowed', 'interaction', 'isolation', 'limit', 'message', 'model', 'new_source', 'new_string', 'notebook_path', 'offset', 'old_string', 'options', 'output_mode', 'path', 'pattern', 'planning', 'productivity', 'prompt', 'query', 'question', 'replace_all', 'run_in_background', 'schedule', 'scheduling', 'session_configs', 'shell_id', 'status', 'subagent_type', 'subject', 'system', 'task', 'taskId', 'timeout', 'to', 'todos', 'tool_categories', 'total_tools', 'trigger', 'url', 'web'] \ No newline at end of file diff --git a/.hypothesis/constants/7c2b91b3ea4d5bae b/.hypothesis/constants/7c2b91b3ea4d5bae new file mode 100644 index 0000000..409771b --- /dev/null +++ b/.hypothesis/constants/7c2b91b3ea4d5bae @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/message_adapter.py +# hypothesis_version: 6.151.4 + +[200, '"', '.*?', '.*?', 'Here is the JSON:', 'Here is the data:', 'Here is the output:', 'Here is the result:', 'Here is your JSON:', "Here's the JSON:", "Here's the data:", "Here's the output:", "Here's the response:", "Here's the result:", "Here's your JSON:", 'JSON response:', 'Output:', 'Response:', 'Result:', 'The JSON is:', '[', '[]', '\\', '\\n\\s*\\n\\s*\\n', ']', '```', '```\n', '```\r\n', '```json\n', '```json\r\n', 'assistant', 'brace_match', 'code_block', 'content', 'direct', 'extracted_length', 'failed', 'fallback', 'fallback_used', 'fallback_value', 'finish_reason', 'method', 'model', 'original_length', 'preamble_found', 'preamble_removed', 'role', 'stop', 'strict_mode', 'success', 'system', 'user', '{', '{[', '}', '}]'] \ No newline at end of file diff --git a/.hypothesis/constants/7cbb728ba70d01ef b/.hypothesis/constants/7cbb728ba70d01ef new file mode 100644 index 0000000..d4ca8b0 --- /dev/null +++ b/.hypothesis/constants/7cbb728ba70d01ef @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/retry.py +# hypothesis_version: 6.151.4 + +[0.25, 400, 401, 429, 500, 529, 1000, 30000, 'connection', 'econnreset', 'epipe', 'timeout'] \ No newline at end of file diff --git a/.hypothesis/constants/8147e68ddedfd20b b/.hypothesis/constants/8147e68ddedfd20b new file mode 100644 index 0000000..cf5690a --- /dev/null +++ b/.hypothesis/constants/8147e68ddedfd20b @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/constants.py +# hypothesis_version: 6.151.4 + +[100, 200, 8000, 600000, 'Bash', 'BashOutput', 'DEFAULT_MODEL', 'Edit', 'Glob', 'Grep', 'KillShell', 'NotebookEdit', 'Read', 'Skill', 'SlashCommand', 'Task', 'TodoWrite', 'WebFetch', 'WebSearch', 'Write', 'claude-opus-4-6', 'claude_code', 'preset', 'text'] \ No newline at end of file diff --git a/.hypothesis/constants/8c6b3f1674b9e0fe b/.hypothesis/constants/8c6b3f1674b9e0fe new file mode 100644 index 0000000..487aaa6 --- /dev/null +++ b/.hypothesis/constants/8c6b3f1674b9e0fe @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/__init__.py +# hypothesis_version: 6.151.4 + +['2.4.2'] \ No newline at end of file diff --git a/.hypothesis/constants/92d90c488a56ada0 b/.hypothesis/constants/92d90c488a56ada0 new file mode 100644 index 0000000..4ab6278 --- /dev/null +++ b/.hypothesis/constants/92d90c488a56ada0 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/constants.py +# hypothesis_version: 6.151.4 + +[0.01, 0.08, 0.1, 0.3, 0.5, 0.8, 1.0, 1.25, 1.5, 3.0, 3.75, 4.0, 5.0, 6.25, 15.0, 18.75, 25.0, 75.0, 100, 200, 8000, 8192, 32000, 64000, 128000, 200000, 600000, 'Agent', 'AskUserQuestion', 'Bash', 'BashOutput', 'CronCreate', 'CronDelete', 'CronList', 'DEFAULT_MODEL', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'ExitPlanMode', 'ExitWorktree', 'Glob', 'Grep', 'KillShell', 'NotebookEdit', 'Read', 'RemoteTrigger', 'SendMessage', 'Skill', 'SlashCommand', 'Task', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'TodoWrite', 'ToolSearch', 'WebFetch', 'WebSearch', 'Write', 'adaptive', 'cache_read', 'cache_write', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude_code', 'context_window', 'default_max_output', 'disabled', 'enabled', 'high', 'input', 'low', 'max', 'max_output_limit', 'medium', 'output', 'preset', 'text'] \ No newline at end of file diff --git a/.hypothesis/constants/9adb793441356481 b/.hypothesis/constants/9adb793441356481 new file mode 100644 index 0000000..dcbc306 --- /dev/null +++ b/.hypothesis/constants/9adb793441356481 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/tool_manager.py +# hypothesis_version: 6.151.4 + +['Bash', 'BashOutput', 'Create a new file', 'Delete notebook cell', 'Edit', 'Execute git status', 'Find TODO comments', 'Glob', 'Grep', 'ID of cell to edit', 'KillShell', 'New cell content', 'NotebookEdit', 'Path to .ipynb file', 'Read', 'Read blog post', 'Read entire file', 'Read images and PDFs', 'Rename a variable', 'Replacement text', 'Run npm install', 'Search query', 'Skill', 'SlashCommand', 'Task', 'Text to replace', 'TodoWrite', 'WebFetch', 'WebSearch', 'Write', 'agent', 'allowed_domains', 'bash_id', 'blocked_domains', 'cell_id', 'cell_type', 'code or markdown', 'command', 'content', 'description', 'edit_mode', 'file', 'file_path', 'filter', 'glob', 'global_allowed', 'global_disallowed', 'limit', 'new_source', 'new_string', 'notebook_path', 'offset', 'old_string', 'output_mode', 'path', 'pattern', 'productivity', 'prompt', 'query', 'replace_all', 'run_in_background', 'session_configs', 'shell_id', 'subagent_type', 'system', 'timeout', 'todos', 'tool_categories', 'total_tools', 'url', 'web'] \ No newline at end of file diff --git a/.hypothesis/constants/a282b0de12e1165d b/.hypothesis/constants/a282b0de12e1165d new file mode 100644 index 0000000..4b9add5 --- /dev/null +++ b/.hypothesis/constants/a282b0de12e1165d @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py +# hypothesis_version: 6.151.4 + +[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Disconnected', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'effort', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'thinking', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/a560162a0935d261 b/.hypothesis/constants/a560162a0935d261 new file mode 100644 index 0000000..b04e961 --- /dev/null +++ b/.hypothesis/constants/a560162a0935d261 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/__init__.py +# hypothesis_version: 6.151.4 + +['2.5.2'] \ No newline at end of file diff --git a/.hypothesis/constants/addbf4cc0fd2c0d3 b/.hypothesis/constants/addbf4cc0fd2c0d3 new file mode 100644 index 0000000..0bbc84e --- /dev/null +++ b/.hypothesis/constants/addbf4cc0fd2c0d3 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/parameter_validator.py +# hypothesis_version: 6.151.4 + +[1.0, 100, 50000, ',', 'acceptEdits', 'allowed_tools', 'bypassPermissions', 'default', 'disallowed_tools', 'frequency_penalty', 'logit_bias', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'messages', 'model', 'n', 'permission_mode', 'plan', 'presence_penalty', 'response_format', 'stop', 'stream', 'suggestions', 'supported_parameters', 'temperature', 'top_p', 'user (for logging)', 'warnings', 'x-claude-max-turns'] \ No newline at end of file diff --git a/.hypothesis/constants/b04074d551450985 b/.hypothesis/constants/b04074d551450985 new file mode 100644 index 0000000..cc6d3ab --- /dev/null +++ b/.hypothesis/constants/b04074d551450985 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/claude_cli.py +# hypothesis_version: 6.151.4 + +[0.0, 1000, 600000, 'Hello', '_', '__dict__', 'assistant', 'claude_code', 'completion_tokens', 'content', 'data', 'duration_ms', 'error_message', 'init', 'is_error', 'message', 'model', 'num_turns', 'preset', 'prompt_tokens', 'result', 'session_id', 'subtype', 'success', 'system', 'text', 'total_cost_usd', 'total_tokens', 'type'] \ No newline at end of file diff --git a/.hypothesis/constants/b557a9a709d4c7cf b/.hypothesis/constants/b557a9a709d4c7cf new file mode 100644 index 0000000..65877f5 --- /dev/null +++ b/.hypothesis/constants/b557a9a709d4c7cf @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/auth.py +# hypothesis_version: 6.151.4 + +[401, '1', 'ANTHROPIC_API_KEY', 'API_KEY', 'AWS_ACCESS_KEY_ID', 'AWS_DEFAULT_REGION', 'AWS_REGION', 'Bearer', 'CLAUDE_AUTH_METHOD', 'CLOUD_ML_REGION', 'Invalid API key', 'Missing API key', 'WWW-Authenticate', 'anthropic', 'api_key', 'api_key_length', 'api_key_present', 'aws_region', 'bedrock', 'claude_cli', 'cli', 'config', 'errors', 'method', 'note', 'project_id', 'region', 'runtime_api_key', 'status', 'valid', 'vertex'] \ No newline at end of file diff --git a/.hypothesis/constants/ba3ef7c1e31eb53a b/.hypothesis/constants/ba3ef7c1e31eb53a new file mode 100644 index 0000000..a053b50 --- /dev/null +++ b/.hypothesis/constants/ba3ef7c1e31eb53a @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/cost_tracker.py +# hypothesis_version: 6.151.4 + +[0.0, 0.3, 3.0, 3.75, 15.0, 1000000, 'active_sessions', 'cache_read', 'cache_write', 'cost_usd', 'input', 'input_tokens', 'model_usage', 'output', 'output_tokens', 'request_count', 'requests', 'session_id', 'total_cost_usd', 'total_input_tokens', 'total_output_tokens', 'total_requests'] \ No newline at end of file diff --git a/.hypothesis/constants/bd1bff39ca7e3f9f b/.hypothesis/constants/bd1bff39ca7e3f9f new file mode 100644 index 0000000..0798c20 --- /dev/null +++ b/.hypothesis/constants/bd1bff39ca7e3f9f @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py +# hypothesis_version: 6.151.4 + +[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'Not Connected', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/c2ebc0a232bcf5ab b/.hypothesis/constants/c2ebc0a232bcf5ab new file mode 100644 index 0000000..11a4c00 --- /dev/null +++ b/.hypothesis/constants/c2ebc0a232bcf5ab @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/claude_cli.py +# hypothesis_version: 6.151.4 + +[0.0, 1000, 600000, 'Hello', '_', '__dict__', 'assistant', 'claude_code', 'completion_tokens', 'content', 'data', 'duration_ms', 'error_message', 'init', 'is_error', 'message', 'model', 'num_turns', 'preset', 'prompt_tokens', 'result', 'session_id', 'status_code', 'subtype', 'success', 'system', 'text', 'total_cost_usd', 'total_tokens', 'type'] \ No newline at end of file diff --git a/.hypothesis/constants/c48321436c435109 b/.hypothesis/constants/c48321436c435109 new file mode 100644 index 0000000..b05a508 --- /dev/null +++ b/.hypothesis/constants/c48321436c435109 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/cpu_watchdog.py +# hypothesis_version: 6.151.4 + +[0.0, 100.0, '/proc/self/stat', '3', '30', '80', 'CPU watchdog stopped', 'SC_CLK_TCK', 'WATCHDOG_ENABLED', 'WATCHDOG_INTERVAL', 'WATCHDOG_STRIKES', 'false', 'linux', 'true'] \ No newline at end of file diff --git a/.hypothesis/constants/c6b66dd364db4aea b/.hypothesis/constants/c6b66dd364db4aea new file mode 100644 index 0000000..d4abd27 --- /dev/null +++ b/.hypothesis/constants/c6b66dd364db4aea @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py +# hypothesis_version: 6.151.4 + +[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'Not Connected', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'effort', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'thinking', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/cc377c555d1180c1 b/.hypothesis/constants/cc377c555d1180c1 new file mode 100644 index 0000000..0278c14 --- /dev/null +++ b/.hypothesis/constants/cc377c555d1180c1 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/message_adapter.py +# hypothesis_version: 6.151.4 + +[b'```\n', b'```\r\n', b'```json\n', b'```json\r\n', 200, '"', '.*?', '.*?', 'Here is the JSON:', 'Here is the data:', 'Here is the output:', 'Here is the result:', 'Here is your JSON:', "Here's the JSON:", "Here's the data:", "Here's the output:", "Here's the response:", "Here's the result:", "Here's your JSON:", 'JSON response:', 'Output:', 'Response:', 'Result:', 'The JSON is:', '[', '[]', '\\', '\\n\\s*\\n\\s*\\n', ']', '```', 'assistant', 'brace_match', 'code_block', 'content', 'direct', 'extracted_length', 'failed', 'fallback', 'fallback_used', 'fallback_value', 'finish_reason', 'method', 'model', 'original_length', 'preamble_found', 'preamble_removed', 'role', 'stop', 'strict_mode', 'success', 'system', 'user', '{', '{[', '}', '}]'] \ No newline at end of file diff --git a/.hypothesis/constants/cd8780436271eddb b/.hypothesis/constants/cd8780436271eddb new file mode 100644 index 0000000..dc82bd3 --- /dev/null +++ b/.hypothesis/constants/cd8780436271eddb @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/constants.py +# hypothesis_version: 6.151.4 + +[0.01, 0.08, 0.1, 0.3, 0.5, 0.8, 1.0, 1.25, 1.5, 3.0, 3.75, 4.0, 5.0, 6.25, 15.0, 18.75, 25.0, 75.0, 100, 200, 8000, 8192, 32000, 64000, 128000, 200000, 600000, 'Agent', 'AskUserQuestion', 'Bash', 'Brief', 'Config', 'CronCreate', 'CronDelete', 'CronList', 'DEFAULT_MODEL', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'ExitPlanMode', 'ExitWorktree', 'Glob', 'Grep', 'ListMcpResources', 'ListPeers', 'Monitor', 'NotebookEdit', 'PushNotification', 'REPL', 'Read', 'ReadMcpResource', 'RemoteTrigger', 'SendMessage', 'SendUserFile', 'Skill', 'Sleep', 'Task', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'TodoWrite', 'ToolSearch', 'VerifyPlanExecution', 'WebFetch', 'WebSearch', 'Write', 'adaptive', 'cache_read', 'cache_write', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude_code', 'context_window', 'default_max_output', 'disabled', 'enabled', 'high', 'input', 'low', 'max', 'max_output_limit', 'medium', 'output', 'preset', 'text'] \ No newline at end of file diff --git a/.hypothesis/constants/cfb85dbb9b5d85a6 b/.hypothesis/constants/cfb85dbb9b5d85a6 new file mode 100644 index 0000000..0798c20 --- /dev/null +++ b/.hypothesis/constants/cfb85dbb9b5d85a6 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py +# hypothesis_version: 6.151.4 + +[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'Not Connected', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/d14c45ee4f738a0e b/.hypothesis/constants/d14c45ee4f738a0e new file mode 100644 index 0000000..09b4faf --- /dev/null +++ b/.hypothesis/constants/d14c45ee4f738a0e @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/session_manager.py +# hypothesis_version: 6.151.4 + +['active_sessions', 'expired_sessions', 'total_messages'] \ No newline at end of file diff --git a/.hypothesis/constants/d434e96105f62824 b/.hypothesis/constants/d434e96105f62824 new file mode 100644 index 0000000..869fce1 --- /dev/null +++ b/.hypothesis/constants/d434e96105f62824 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/cost_tracker.py +# hypothesis_version: 6.151.4 + +[0.0, 0.3, 3.0, 3.75, 15.0, 1000000, 'active_sessions', 'cache_read', 'cache_write', 'claude-sonnet-4-6', 'cost_usd', 'input', 'input_tokens', 'model_usage', 'output', 'output_tokens', 'request_count', 'requests', 'session_id', 'total_cost_usd', 'total_input_tokens', 'total_output_tokens', 'total_requests'] \ No newline at end of file diff --git a/.hypothesis/constants/d834e79418fe5fa5 b/.hypothesis/constants/d834e79418fe5fa5 new file mode 100644 index 0000000..4ab6278 --- /dev/null +++ b/.hypothesis/constants/d834e79418fe5fa5 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/constants.py +# hypothesis_version: 6.151.4 + +[0.01, 0.08, 0.1, 0.3, 0.5, 0.8, 1.0, 1.25, 1.5, 3.0, 3.75, 4.0, 5.0, 6.25, 15.0, 18.75, 25.0, 75.0, 100, 200, 8000, 8192, 32000, 64000, 128000, 200000, 600000, 'Agent', 'AskUserQuestion', 'Bash', 'BashOutput', 'CronCreate', 'CronDelete', 'CronList', 'DEFAULT_MODEL', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'ExitPlanMode', 'ExitWorktree', 'Glob', 'Grep', 'KillShell', 'NotebookEdit', 'Read', 'RemoteTrigger', 'SendMessage', 'Skill', 'SlashCommand', 'Task', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'TodoWrite', 'ToolSearch', 'WebFetch', 'WebSearch', 'Write', 'adaptive', 'cache_read', 'cache_write', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude_code', 'context_window', 'default_max_output', 'disabled', 'enabled', 'high', 'input', 'low', 'max', 'max_output_limit', 'medium', 'output', 'preset', 'text'] \ No newline at end of file diff --git a/.hypothesis/constants/d84afc418365a945 b/.hypothesis/constants/d84afc418365a945 new file mode 100644 index 0000000..7514e7d --- /dev/null +++ b/.hypothesis/constants/d84afc418365a945 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/tool_manager.py +# hypothesis_version: 6.151.4 + +['Agent', 'AskUserQuestion', 'Available choices', 'Bash', 'Brief', 'Code to execute', 'Command to run', 'Config', 'Config key', 'Config value', 'Create a new file', 'CronCreate', 'CronDelete', 'CronList', 'Delete notebook cell', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'Execute git status', 'ExitPlanMode', 'ExitWorktree', 'Find TODO comments', 'Glob', 'Grep', 'ID of cell to edit', 'List all tasks', 'ListMcpResources', 'ListPeers', 'MCP server name', 'Message content', 'Monitor', 'New cell content', 'New status', 'NotebookEdit', 'Notification body', 'Notification title', 'Optional arguments', 'Path to .ipynb file', 'Programming language', 'PushNotification', 'Question to ask', 'REPL', 'Read', 'Read blog post', 'Read current config', 'Read entire file', 'Read images and PDFs', 'ReadMcpResource', 'RemoteTrigger', 'Rename a variable', 'Replacement text', 'Resource URI', 'Run commit skill', 'Run npm install', 'Search query', 'SendMessage', 'SendUserFile', 'Skill', 'Sleep', 'Stop a running task', 'Task', 'Task ID', 'Task ID to retrieve', 'Task ID to stop', 'Task description', 'Task subject', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'Text to replace', 'TodoWrite', 'ToolSearch', 'Update a setting', 'Verbosity level', 'VerifyPlanExecution', 'WebFetch', 'WebSearch', 'Write', 'action', 'agent', 'allowed_domains', 'args', 'blocked_domains', 'body', 'branch', 'cell_id', 'cell_type', 'code', 'code or markdown', 'command', 'content', 'cronId', 'description', 'discovery', 'duration', 'edit_mode', 'file', 'file_path', 'git', 'glob', 'global_allowed', 'global_disallowed', 'interaction', 'isolation', 'key', 'language', 'level', 'limit', 'mcp', 'message', 'model', 'new_source', 'new_string', 'notebook_path', 'notification', 'offset', 'old_string', 'options', 'output', 'output_mode', 'path', 'pattern', 'plan_id', 'planning', 'productivity', 'prompt', 'query', 'question', 'read or write', 'replace_all', 'run_in_background', 'schedule', 'scheduling', 'server', 'session_configs', 'skill', 'status', 'subagent_type', 'subject', 'system', 'target', 'task', 'taskId', 'timeout', 'title', 'to', 'todos', 'tool_categories', 'total_tools', 'trigger', 'uri', 'url', 'value', 'web'] \ No newline at end of file diff --git a/.hypothesis/constants/db4f54cef59f98f2 b/.hypothesis/constants/db4f54cef59f98f2 new file mode 100644 index 0000000..cff5fe4 --- /dev/null +++ b/.hypothesis/constants/db4f54cef59f98f2 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py +# hypothesis_version: 6.151.4 + +[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'Not Connected', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/dea42edc03d45162 b/.hypothesis/constants/dea42edc03d45162 new file mode 100644 index 0000000..64e376d --- /dev/null +++ b/.hypothesis/constants/dea42edc03d45162 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/function_calling.py +# hypothesis_version: 6.151.4 + +['No description', 'arguments', 'assistant', 'content', 'description', 'function', 'name', 'none', 'parameters', 'required', 'role', 'tool', 'tool_call_id', 'tool_calls', 'unknown', 'user', '{}'] \ No newline at end of file diff --git a/.hypothesis/constants/eb715738993787bc b/.hypothesis/constants/eb715738993787bc new file mode 100644 index 0000000..b05a508 --- /dev/null +++ b/.hypothesis/constants/eb715738993787bc @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/cpu_watchdog.py +# hypothesis_version: 6.151.4 + +[0.0, 100.0, '/proc/self/stat', '3', '30', '80', 'CPU watchdog stopped', 'SC_CLK_TCK', 'WATCHDOG_ENABLED', 'WATCHDOG_INTERVAL', 'WATCHDOG_STRIKES', 'false', 'linux', 'true'] \ No newline at end of file diff --git a/.hypothesis/constants/f070aebf9a1fa192 b/.hypothesis/constants/f070aebf9a1fa192 new file mode 100644 index 0000000..abb0d2d --- /dev/null +++ b/.hypothesis/constants/f070aebf9a1fa192 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/__init__.py +# hypothesis_version: 6.151.4 + +['2.5.0'] \ No newline at end of file diff --git a/.hypothesis/constants/f0942b966cd1a673 b/.hypothesis/constants/f0942b966cd1a673 new file mode 100644 index 0000000..bf90daa --- /dev/null +++ b/.hypothesis/constants/f0942b966cd1a673 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py +# hypothesis_version: 6.151.4 + +[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Disconnected', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'effort', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'json_schema', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'thinking', 'tool_calls', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/f102fa85cdaff8e2 b/.hypothesis/constants/f102fa85cdaff8e2 new file mode 100644 index 0000000..d28e0d8 --- /dev/null +++ b/.hypothesis/constants/f102fa85cdaff8e2 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/mcp_client.py +# hypothesis_version: 6.151.4 + +['arguments', 'connected', 'connected_servers', 'description', 'enabled', 'inputSchema', 'input_schema', 'mcp_available', 'mimeType', 'name', 'prompts', 'registered_servers', 'resources', 'servers', 'tools', 'total_prompts', 'total_resources', 'total_tools', 'uri'] \ No newline at end of file diff --git a/.hypothesis/constants/f421bd7fea970ca8 b/.hypothesis/constants/f421bd7fea970ca8 new file mode 100644 index 0000000..769feb3 --- /dev/null +++ b/.hypothesis/constants/f421bd7fea970ca8 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/parameter_validator.py +# hypothesis_version: 6.151.4 + +[1.0, 100, 50000, ',', 'acceptEdits', 'allowed_tools', 'bypassPermissions', 'default', 'disallowed_tools', 'effort', 'frequency_penalty', 'logit_bias', 'max_output_limit', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'messages', 'model', 'n', 'permission_mode', 'plan', 'presence_penalty', 'response_format', 'stop', 'stream', 'suggestions', 'supported_parameters', 'temperature', 'thinking', 'top_p', 'user (for logging)', 'warnings', 'x-claude-effort', 'x-claude-max-turns', 'x-claude-thinking'] \ No newline at end of file diff --git a/.hypothesis/constants/fb8091c3914026d9 b/.hypothesis/constants/fb8091c3914026d9 new file mode 100644 index 0000000..e76431a --- /dev/null +++ b/.hypothesis/constants/fb8091c3914026d9 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/request_cache.py +# hypothesis_version: 6.151.4 + +[100, '1', '100', '60', 'current_size', 'enabled', 'evictions', 'expirations', 'false', 'hit_rate_percent', 'hits', 'max_size', 'max_tokens', 'messages', 'misses', 'model', 'on', 'response_format', 'temperature', 'top_p', 'true', 'ttl_seconds', 'yes'] \ No newline at end of file diff --git a/.hypothesis/constants/fbd667538a3b64b4 b/.hypothesis/constants/fbd667538a3b64b4 new file mode 100644 index 0000000..869fce1 --- /dev/null +++ b/.hypothesis/constants/fbd667538a3b64b4 @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/cost_tracker.py +# hypothesis_version: 6.151.4 + +[0.0, 0.3, 3.0, 3.75, 15.0, 1000000, 'active_sessions', 'cache_read', 'cache_write', 'claude-sonnet-4-6', 'cost_usd', 'input', 'input_tokens', 'model_usage', 'output', 'output_tokens', 'request_count', 'requests', 'session_id', 'total_cost_usd', 'total_input_tokens', 'total_output_tokens', 'total_requests'] \ No newline at end of file diff --git a/.hypothesis/constants/fe53ac5fa2ae2faf b/.hypothesis/constants/fe53ac5fa2ae2faf new file mode 100644 index 0000000..55bf8e2 --- /dev/null +++ b/.hypothesis/constants/fe53ac5fa2ae2faf @@ -0,0 +1,4 @@ +# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/tool_manager.py +# hypothesis_version: 6.151.4 + +['Agent', 'AskUserQuestion', 'Available choices', 'Bash', 'BashOutput', 'Command to run', 'Create a new file', 'CronCreate', 'CronDelete', 'CronList', 'Delete notebook cell', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'Execute git status', 'ExitPlanMode', 'ExitWorktree', 'Find TODO comments', 'Glob', 'Grep', 'ID of cell to edit', 'KillShell', 'List all tasks', 'Message content', 'New cell content', 'New status', 'NotebookEdit', 'Path to .ipynb file', 'Question to ask', 'Read', 'Read blog post', 'Read entire file', 'Read images and PDFs', 'RemoteTrigger', 'Rename a variable', 'Replacement text', 'Run npm install', 'Search query', 'SendMessage', 'Skill', 'SlashCommand', 'Stop a running task', 'Task', 'Task ID', 'Task ID to retrieve', 'Task ID to stop', 'Task description', 'Task subject', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'Text to replace', 'TodoWrite', 'ToolSearch', 'WebFetch', 'WebSearch', 'Write', 'agent', 'allowed_domains', 'bash_id', 'blocked_domains', 'branch', 'cell_id', 'cell_type', 'code or markdown', 'command', 'content', 'cronId', 'description', 'discovery', 'edit_mode', 'file', 'file_path', 'filter', 'git', 'glob', 'global_allowed', 'global_disallowed', 'interaction', 'isolation', 'limit', 'message', 'model', 'new_source', 'new_string', 'notebook_path', 'offset', 'old_string', 'options', 'output_mode', 'path', 'pattern', 'planning', 'productivity', 'prompt', 'query', 'question', 'replace_all', 'run_in_background', 'schedule', 'scheduling', 'session_configs', 'shell_id', 'status', 'subagent_type', 'subject', 'system', 'task', 'taskId', 'timeout', 'to', 'todos', 'tool_categories', 'total_tools', 'trigger', 'url', 'web'] \ No newline at end of file diff --git a/.hypothesis/unicode_data/14.0.0/charmap.json.gz b/.hypothesis/unicode_data/14.0.0/charmap.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..d0054c610a618e7b0b411610e5ec51c7c134365e GIT binary patch literal 21505 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 literal 0 HcmV?d00001 diff --git a/src/function_calling.py b/src/function_calling.py index e9a45b8..f5cafac 100644 --- a/src/function_calling.py +++ b/src/function_calling.py @@ -49,7 +49,7 @@ def build_tools_system_prompt(tools: list, tool_choice=None) -> str: return "\n".join(parts) -def parse_tool_calls(response_text: str) -> tuple: +def parse_tool_calls(response_text: str) -> tuple[list, str]: # Primary: fenced tool_calls block pattern = r"```tool_calls\s*\n(.*?)```" match = re.search(pattern, response_text, re.DOTALL) @@ -63,18 +63,23 @@ def parse_tool_calls(response_text: str) -> tuple: except json.JSONDecodeError: logger.warning("Found tool_calls block but failed to parse JSON") - # Fallback: bare JSON array starting with [{"name": - bare_pattern = r'(\[\s*\{\s*"name"\s*:.*\])' - bare_match = re.search(bare_pattern, response_text, re.DOTALL) + # Fallback: find [{"name": and try to parse valid JSON from that position + bare_pattern = r'\[\s*\{\s*"name"\s*:' + bare_match = re.search(bare_pattern, response_text) if bare_match: - try: - calls = json.loads(bare_match.group(1)) - remaining = response_text[:bare_match.start()] + response_text[bare_match.end():] - remaining = remaining.strip() - return (calls, remaining) - except json.JSONDecodeError: - logger.warning("Found bare JSON array but failed to parse") + start = bare_match.start() + # Try increasingly longer substrings to find valid JSON + for end in range(len(response_text), start, -1): + if response_text[end - 1] == ']': + try: + calls = json.loads(response_text[start:end]) + remaining = response_text[:start] + response_text[end:] + remaining = remaining.strip() + return (calls, remaining) + except json.JSONDecodeError: + continue + logger.warning("Found bare JSON array marker but failed to parse") return ([], response_text) diff --git a/src/main.py b/src/main.py index b6c3246..9248cdb 100644 --- a/src/main.py +++ b/src/main.py @@ -557,6 +557,9 @@ async def generate_streaming_response( tool_call_buffer = [] # Buffer when tools are defined - parse at end fence_stripper = JsonFenceStripper() if json_mode else None + if has_tools and json_mode: + logger.info("Both tools and JSON mode active -- tools take priority for buffering") + async for chunk in claude_cli.run_completion( **_run_completion_kwargs(claude_options, prompt, system_prompt, stream=True), ): @@ -938,17 +941,7 @@ async def chat_completions( # JSON schema mode: inject schema into prompt (not system_prompt) schema = request_body.response_format.json_schema schema_json = json.dumps(schema.schema_ or {}, indent=2) - schema_instructions = ( - "You MUST respond with valid JSON that strictly conforms to the following JSON Schema.\n" - "Do not wrap the JSON in markdown code fences.\n" - "Do not include any text before or after the JSON.\n" - "RULES:\n" - "- Include ALL required properties from the schema, even if empty or default\n" - "- Use the EXACT property names from the schema\n" - "- Match the EXACT types specified (number not string, etc.)\n" - "- Do not add properties not in the schema\n\n" - f"JSON Schema:\n{schema_json}" - ) + schema_instructions = MessageAdapter.JSON_SCHEMA_TEMPLATE.format(schema_json=schema_json) prompt = f"{schema_instructions}\n\n{prompt}" logger.info(f"JSON schema mode: injected schema ({len(schema_json)} chars) into prompt") else: diff --git a/src/message_adapter.py b/src/message_adapter.py index d18ca86..5cba5dc 100644 --- a/src/message_adapter.py +++ b/src/message_adapter.py @@ -41,8 +41,7 @@ def process_delta(self, chunk: str) -> str: if len(self._opening_buf) < self._MAX_FENCE_LEN: # Still accumulating -- check if it could be a fence prefix for fence in self._FENCES: - fence_str = fence - if fence_str.startswith(self._opening_buf): + if fence.startswith(self._opening_buf): return "" # could still match, hold back # No fence can match, release buffer self._opening_stripped = True @@ -53,9 +52,8 @@ def process_delta(self, chunk: str) -> str: # Buffer full -- check for fence match self._opening_stripped = True for fence in self._FENCES: - fence_str = fence - if self._opening_buf.startswith(fence_str): - remainder = self._opening_buf[len(fence_str):] + if self._opening_buf.startswith(fence): + remainder = self._opening_buf[len(fence):] self._opening_buf = "" return self._apply_holdback(remainder) # No match, release everything @@ -595,7 +593,7 @@ def filter_content(content: str) -> str: Remove thinking blocks, tool calls, and image references. """ if not content: - return content + return content or "" # Remove thinking blocks (common when tools are disabled but Claude tries to think) thinking_pattern = r".*?" diff --git a/tests/test_cpu_watchdog_unit.py b/tests/test_cpu_watchdog_unit.py index 39dab11..a97cbd7 100644 --- a/tests/test_cpu_watchdog_unit.py +++ b/tests/test_cpu_watchdog_unit.py @@ -39,3 +39,17 @@ def test_start_non_linux(self): def test_stop_no_task(self): wd = CPUWatchdog() wd.stop() # should not raise + + def test_strike_increment_and_reset(self): + wd = CPUWatchdog() + wd._strikes = 2 + # Simulating a below-threshold reading resets strikes + wd._strikes = 0 + assert wd._strikes == 0 + + def test_env_vars_read_at_import(self): + from src.cpu_watchdog import WATCHDOG_ENABLED, WATCHDOG_INTERVAL, WATCHDOG_CPU_THRESHOLD, WATCHDOG_STRIKES + assert isinstance(WATCHDOG_ENABLED, bool) + assert isinstance(WATCHDOG_INTERVAL, int) + assert isinstance(WATCHDOG_CPU_THRESHOLD, float) + assert isinstance(WATCHDOG_STRIKES, int) diff --git a/tests/test_function_calling_unit.py b/tests/test_function_calling_unit.py index d3c25dc..027e76b 100644 --- a/tests/test_function_calling_unit.py +++ b/tests/test_function_calling_unit.py @@ -172,3 +172,30 @@ def test_mixed_conversation(self): assert result[0].role == "user" assert result[1].role == "assistant" assert result[2].role == "user" + + def test_convert_dict_messages(self): + messages = [ + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "c1", "type": "function", "function": {"name": "search", "arguments": '{"q": "test"}'}} + ]}, + {"role": "tool", "content": "results", "name": "search", "tool_call_id": "c1"}, + ] + result = convert_tool_messages(messages) + assert len(result) == 2 + assert result[0].role == "assistant" + assert "Called search" in result[0].content + assert result[1].role == "user" + assert "Result of search" in result[1].content + + +class TestParseToolCallsEdgeCases: + def test_nested_arrays_in_arguments(self): + text = '[{"name": "fn", "arguments": {"items": [1, 2, 3]}}]' + calls, remaining = parse_tool_calls(text) + assert len(calls) == 1 + assert calls[0]["arguments"]["items"] == [1, 2, 3] + + def test_tool_choice_dict_in_prompt(self): + choice = {"type": "function", "function": {"name": "search"}} + result = build_tools_system_prompt(SAMPLE_TOOLS, choice) + assert "MUST call function search" in result diff --git a/tests/test_json_format_unit.py b/tests/test_json_format_unit.py index 7473b26..a9ca5d0 100644 --- a/tests/test_json_format_unit.py +++ b/tests/test_json_format_unit.py @@ -221,6 +221,33 @@ def test_response_format_dict_input(self): ) assert request.response_format.type == "json_object" + def test_response_format_json_schema(self): + """json_schema type with schema definition.""" + rf = ResponseFormat( + type="json_schema", + json_schema={"name": "test", "schema": {"type": "object", "properties": {"x": {"type": "number"}}}}, + ) + assert rf.type == "json_schema" + assert rf.json_schema is not None + assert rf.json_schema.name == "test" + assert rf.json_schema.schema_ is not None + assert rf.json_schema.schema_["type"] == "object" + + def test_response_format_json_schema_in_request(self): + """json_schema type works in ChatCompletionRequest.""" + request = ChatCompletionRequest( + messages=[Message(role="user", content="Return JSON")], + response_format={ + "type": "json_schema", + "json_schema": { + "name": "colors", + "schema": {"type": "object", "properties": {"colors": {"type": "array"}}}, + }, + }, + ) + assert request.response_format.type == "json_schema" + assert request.response_format.json_schema.name == "colors" + class TestJsonModeInstruction: """Test JSON_MODE_INSTRUCTION constant.""" diff --git a/tests/test_message_adapter_unit.py b/tests/test_message_adapter_unit.py index 90f3c52..882b9db 100644 --- a/tests/test_message_adapter_unit.py +++ b/tests/test_message_adapter_unit.py @@ -93,7 +93,7 @@ class TestFilterContent: def test_empty_content_returns_empty(self): """Empty content returns empty.""" assert MessageAdapter.filter_content("") == "" - assert MessageAdapter.filter_content(None) is None + assert MessageAdapter.filter_content(None) == "" def test_plain_text_unchanged(self): """Plain text content is unchanged.""" From 750cf9c537d651bd73bf448e65a0446e7a61bded Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Thu, 2 Apr 2026 19:01:33 -0400 Subject: [PATCH 16/38] chore: remove .hypothesis test cache, add to gitignore --- .gitignore | 2 +- .hypothesis/constants/0b9f5d19f0cc2503 | 4 ---- .hypothesis/constants/1592681e34a69c28 | 4 ---- .hypothesis/constants/1ac918ed1f76c9f4 | 4 ---- .hypothesis/constants/1d2c6cf78e4a0d3b | 4 ---- .hypothesis/constants/24fcd0f4bf56b6a5 | 4 ---- .hypothesis/constants/2be0ce1ce4912b41 | 4 ---- .hypothesis/constants/33a6c4f86be05bf0 | 4 ---- .hypothesis/constants/3bbed57e7f5f907a | 4 ---- .hypothesis/constants/3bedde4e911abb67 | 4 ---- .hypothesis/constants/416f667f337eef4d | 4 ---- .hypothesis/constants/49266abea451322c | 4 ---- .hypothesis/constants/4bfa246f2ad136a7 | 4 ---- .hypothesis/constants/4ff5447358cce36d | 4 ---- .hypothesis/constants/5a015d1988280896 | 4 ---- .hypothesis/constants/5eace2102a943108 | 4 ---- .hypothesis/constants/5ec5250a39fbf461 | 4 ---- .hypothesis/constants/5ecb8d27c15539fb | 4 ---- .hypothesis/constants/5f1ff972bb16d351 | 4 ---- .hypothesis/constants/5fa7e22095c251de | 4 ---- .hypothesis/constants/62961dda076a1c18 | 4 ---- .hypothesis/constants/6720b331c8de2f4d | 4 ---- .hypothesis/constants/6945d5fe75d7baf9 | 4 ---- .hypothesis/constants/6a1bdddafd3867b0 | 4 ---- .hypothesis/constants/6c388abca123fca7 | 4 ---- .hypothesis/constants/6f4af6e3fb4bf935 | 4 ---- .hypothesis/constants/79a494eefa2125eb | 4 ---- .hypothesis/constants/7c2b91b3ea4d5bae | 4 ---- .hypothesis/constants/7cbb728ba70d01ef | 4 ---- .hypothesis/constants/8147e68ddedfd20b | 4 ---- .hypothesis/constants/8c6b3f1674b9e0fe | 4 ---- .hypothesis/constants/92d90c488a56ada0 | 4 ---- .hypothesis/constants/9adb793441356481 | 4 ---- .hypothesis/constants/a282b0de12e1165d | 4 ---- .hypothesis/constants/a560162a0935d261 | 4 ---- .hypothesis/constants/addbf4cc0fd2c0d3 | 4 ---- .hypothesis/constants/b04074d551450985 | 4 ---- .hypothesis/constants/b557a9a709d4c7cf | 4 ---- .hypothesis/constants/ba3ef7c1e31eb53a | 4 ---- .hypothesis/constants/bd1bff39ca7e3f9f | 4 ---- .hypothesis/constants/c2ebc0a232bcf5ab | 4 ---- .hypothesis/constants/c48321436c435109 | 4 ---- .hypothesis/constants/c6b66dd364db4aea | 4 ---- .hypothesis/constants/cc377c555d1180c1 | 4 ---- .hypothesis/constants/cd8780436271eddb | 4 ---- .hypothesis/constants/cfb85dbb9b5d85a6 | 4 ---- .hypothesis/constants/d14c45ee4f738a0e | 4 ---- .hypothesis/constants/d434e96105f62824 | 4 ---- .hypothesis/constants/d834e79418fe5fa5 | 4 ---- .hypothesis/constants/d84afc418365a945 | 4 ---- .hypothesis/constants/db4f54cef59f98f2 | 4 ---- .hypothesis/constants/dea42edc03d45162 | 4 ---- .hypothesis/constants/eb715738993787bc | 4 ---- .hypothesis/constants/f070aebf9a1fa192 | 4 ---- .hypothesis/constants/f0942b966cd1a673 | 4 ---- .hypothesis/constants/f102fa85cdaff8e2 | 4 ---- .hypothesis/constants/f421bd7fea970ca8 | 4 ---- .hypothesis/constants/fb8091c3914026d9 | 4 ---- .hypothesis/constants/fbd667538a3b64b4 | 4 ---- .hypothesis/constants/fe53ac5fa2ae2faf | 4 ---- .hypothesis/unicode_data/14.0.0/charmap.json.gz | Bin 21505 -> 0 bytes .../unicode_data/14.0.0/codec-utf-8.json.gz | Bin 60 -> 0 bytes 62 files changed, 1 insertion(+), 237 deletions(-) delete mode 100644 .hypothesis/constants/0b9f5d19f0cc2503 delete mode 100644 .hypothesis/constants/1592681e34a69c28 delete mode 100644 .hypothesis/constants/1ac918ed1f76c9f4 delete mode 100644 .hypothesis/constants/1d2c6cf78e4a0d3b delete mode 100644 .hypothesis/constants/24fcd0f4bf56b6a5 delete mode 100644 .hypothesis/constants/2be0ce1ce4912b41 delete mode 100644 .hypothesis/constants/33a6c4f86be05bf0 delete mode 100644 .hypothesis/constants/3bbed57e7f5f907a delete mode 100644 .hypothesis/constants/3bedde4e911abb67 delete mode 100644 .hypothesis/constants/416f667f337eef4d delete mode 100644 .hypothesis/constants/49266abea451322c delete mode 100644 .hypothesis/constants/4bfa246f2ad136a7 delete mode 100644 .hypothesis/constants/4ff5447358cce36d delete mode 100644 .hypothesis/constants/5a015d1988280896 delete mode 100644 .hypothesis/constants/5eace2102a943108 delete mode 100644 .hypothesis/constants/5ec5250a39fbf461 delete mode 100644 .hypothesis/constants/5ecb8d27c15539fb delete mode 100644 .hypothesis/constants/5f1ff972bb16d351 delete mode 100644 .hypothesis/constants/5fa7e22095c251de delete mode 100644 .hypothesis/constants/62961dda076a1c18 delete mode 100644 .hypothesis/constants/6720b331c8de2f4d delete mode 100644 .hypothesis/constants/6945d5fe75d7baf9 delete mode 100644 .hypothesis/constants/6a1bdddafd3867b0 delete mode 100644 .hypothesis/constants/6c388abca123fca7 delete mode 100644 .hypothesis/constants/6f4af6e3fb4bf935 delete mode 100644 .hypothesis/constants/79a494eefa2125eb delete mode 100644 .hypothesis/constants/7c2b91b3ea4d5bae delete mode 100644 .hypothesis/constants/7cbb728ba70d01ef delete mode 100644 .hypothesis/constants/8147e68ddedfd20b delete mode 100644 .hypothesis/constants/8c6b3f1674b9e0fe delete mode 100644 .hypothesis/constants/92d90c488a56ada0 delete mode 100644 .hypothesis/constants/9adb793441356481 delete mode 100644 .hypothesis/constants/a282b0de12e1165d delete mode 100644 .hypothesis/constants/a560162a0935d261 delete mode 100644 .hypothesis/constants/addbf4cc0fd2c0d3 delete mode 100644 .hypothesis/constants/b04074d551450985 delete mode 100644 .hypothesis/constants/b557a9a709d4c7cf delete mode 100644 .hypothesis/constants/ba3ef7c1e31eb53a delete mode 100644 .hypothesis/constants/bd1bff39ca7e3f9f delete mode 100644 .hypothesis/constants/c2ebc0a232bcf5ab delete mode 100644 .hypothesis/constants/c48321436c435109 delete mode 100644 .hypothesis/constants/c6b66dd364db4aea delete mode 100644 .hypothesis/constants/cc377c555d1180c1 delete mode 100644 .hypothesis/constants/cd8780436271eddb delete mode 100644 .hypothesis/constants/cfb85dbb9b5d85a6 delete mode 100644 .hypothesis/constants/d14c45ee4f738a0e delete mode 100644 .hypothesis/constants/d434e96105f62824 delete mode 100644 .hypothesis/constants/d834e79418fe5fa5 delete mode 100644 .hypothesis/constants/d84afc418365a945 delete mode 100644 .hypothesis/constants/db4f54cef59f98f2 delete mode 100644 .hypothesis/constants/dea42edc03d45162 delete mode 100644 .hypothesis/constants/eb715738993787bc delete mode 100644 .hypothesis/constants/f070aebf9a1fa192 delete mode 100644 .hypothesis/constants/f0942b966cd1a673 delete mode 100644 .hypothesis/constants/f102fa85cdaff8e2 delete mode 100644 .hypothesis/constants/f421bd7fea970ca8 delete mode 100644 .hypothesis/constants/fb8091c3914026d9 delete mode 100644 .hypothesis/constants/fbd667538a3b64b4 delete mode 100644 .hypothesis/constants/fe53ac5fa2ae2faf delete mode 100644 .hypothesis/unicode_data/14.0.0/charmap.json.gz delete mode 100644 .hypothesis/unicode_data/14.0.0/codec-utf-8.json.gz diff --git a/.gitignore b/.gitignore index a59cdee..5f5dc85 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,4 @@ test_debug_*.py test_performance_*.py test_user_*.py test_new_*.py -test_roocode_compatibility.py \ No newline at end of file +test_roocode_compatibility.py.hypothesis/ diff --git a/.hypothesis/constants/0b9f5d19f0cc2503 b/.hypothesis/constants/0b9f5d19f0cc2503 deleted file mode 100644 index af274b2..0000000 --- a/.hypothesis/constants/0b9f5d19f0cc2503 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/models.py -# hypothesis_version: 6.151.4 - -[0.3, 0.5, 0.7, 0.9, 1.0, 1.5, 100, 200, 500, 4096, '-_.', 'Response format type', 'System prompt', 'after', 'assistant', 'chat.completion', 'command', 'content_filter', 'end_turn', 'function', 'json_object', 'json_schema', 'length', 'max_thinking_tokens', 'max_tokens', 'message', 'model', 'n', 'name', 'null', 'populate_by_name', 'schema', 'server_name', 'stop', 'stop_sequence', 'system', 'text', 'tool', 'tool_calls', 'tool_name', 'type', 'user'] \ No newline at end of file diff --git a/.hypothesis/constants/1592681e34a69c28 b/.hypothesis/constants/1592681e34a69c28 deleted file mode 100644 index 17f5116..0000000 --- a/.hypothesis/constants/1592681e34a69c28 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/message_adapter.py -# hypothesis_version: 6.151.4 - -[200, '"', '.*?', '.*?', 'Here is the JSON:', 'Here is the data:', 'Here is the output:', 'Here is the result:', 'Here is your JSON:', "Here's the JSON:", "Here's the data:", "Here's the output:", "Here's the response:", "Here's the result:", "Here's your JSON:", 'JSON response:', 'Output:', 'Response:', 'Result:', 'The JSON is:', '[', '[]', '\\', '\\n\\s*\\n\\s*\\n', ']', '```', 'assistant', 'brace_match', 'code_block', 'content', 'direct', 'extracted_length', 'failed', 'fallback', 'fallback_used', 'fallback_value', 'finish_reason', 'method', 'model', 'original_length', 'preamble_found', 'preamble_removed', 'role', 'stop', 'strict_mode', 'success', 'system', 'user', '{', '{[', '}', '}]'] \ No newline at end of file diff --git a/.hypothesis/constants/1ac918ed1f76c9f4 b/.hypothesis/constants/1ac918ed1f76c9f4 deleted file mode 100644 index bf90daa..0000000 --- a/.hypothesis/constants/1ac918ed1f76c9f4 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py -# hypothesis_version: 6.151.4 - -[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Disconnected', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'effort', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'json_schema', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'thinking', 'tool_calls', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/1d2c6cf78e4a0d3b b/.hypothesis/constants/1d2c6cf78e4a0d3b deleted file mode 100644 index eb38c4a..0000000 --- a/.hypothesis/constants/1d2c6cf78e4a0d3b +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/__init__.py -# hypothesis_version: 6.151.4 - -['2.4.1'] \ No newline at end of file diff --git a/.hypothesis/constants/24fcd0f4bf56b6a5 b/.hypothesis/constants/24fcd0f4bf56b6a5 deleted file mode 100644 index 7ef59bf..0000000 --- a/.hypothesis/constants/24fcd0f4bf56b6a5 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/model_service.py -# hypothesis_version: 6.151.4 - -[10.0, 200, 401, 429, '2023-06-01', 'ANTHROPIC_API_KEY', 'anthropic-version', 'data', 'id', 'x-api-key'] \ No newline at end of file diff --git a/.hypothesis/constants/2be0ce1ce4912b41 b/.hypothesis/constants/2be0ce1ce4912b41 deleted file mode 100644 index 4ab6278..0000000 --- a/.hypothesis/constants/2be0ce1ce4912b41 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/constants.py -# hypothesis_version: 6.151.4 - -[0.01, 0.08, 0.1, 0.3, 0.5, 0.8, 1.0, 1.25, 1.5, 3.0, 3.75, 4.0, 5.0, 6.25, 15.0, 18.75, 25.0, 75.0, 100, 200, 8000, 8192, 32000, 64000, 128000, 200000, 600000, 'Agent', 'AskUserQuestion', 'Bash', 'BashOutput', 'CronCreate', 'CronDelete', 'CronList', 'DEFAULT_MODEL', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'ExitPlanMode', 'ExitWorktree', 'Glob', 'Grep', 'KillShell', 'NotebookEdit', 'Read', 'RemoteTrigger', 'SendMessage', 'Skill', 'SlashCommand', 'Task', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'TodoWrite', 'ToolSearch', 'WebFetch', 'WebSearch', 'Write', 'adaptive', 'cache_read', 'cache_write', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude_code', 'context_window', 'default_max_output', 'disabled', 'enabled', 'high', 'input', 'low', 'max', 'max_output_limit', 'medium', 'output', 'preset', 'text'] \ No newline at end of file diff --git a/.hypothesis/constants/33a6c4f86be05bf0 b/.hypothesis/constants/33a6c4f86be05bf0 deleted file mode 100644 index d4abd27..0000000 --- a/.hypothesis/constants/33a6c4f86be05bf0 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py -# hypothesis_version: 6.151.4 - -[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'Not Connected', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'effort', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'thinking', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/3bbed57e7f5f907a b/.hypothesis/constants/3bbed57e7f5f907a deleted file mode 100644 index ea9c0b9..0000000 --- a/.hypothesis/constants/3bbed57e7f5f907a +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/function_calling.py -# hypothesis_version: 6.151.4 - -['No description', 'arguments', 'assistant', 'content', 'description', 'function', 'id', 'name', 'none', 'parameters', 'required', 'role', 'tool', 'tool_call_id', 'tool_calls', 'type', 'unknown', 'user', '{}'] \ No newline at end of file diff --git a/.hypothesis/constants/3bedde4e911abb67 b/.hypothesis/constants/3bedde4e911abb67 deleted file mode 100644 index 59d8292..0000000 --- a/.hypothesis/constants/3bedde4e911abb67 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/retry.py -# hypothesis_version: 6.151.4 - -[0.25, 401, 429, 500, 529, 1000, 30000, 'connection', 'context', 'econnreset', 'epipe', 'overflow', 'timeout', 'too long'] \ No newline at end of file diff --git a/.hypothesis/constants/416f667f337eef4d b/.hypothesis/constants/416f667f337eef4d deleted file mode 100644 index 55bf8e2..0000000 --- a/.hypothesis/constants/416f667f337eef4d +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/tool_manager.py -# hypothesis_version: 6.151.4 - -['Agent', 'AskUserQuestion', 'Available choices', 'Bash', 'BashOutput', 'Command to run', 'Create a new file', 'CronCreate', 'CronDelete', 'CronList', 'Delete notebook cell', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'Execute git status', 'ExitPlanMode', 'ExitWorktree', 'Find TODO comments', 'Glob', 'Grep', 'ID of cell to edit', 'KillShell', 'List all tasks', 'Message content', 'New cell content', 'New status', 'NotebookEdit', 'Path to .ipynb file', 'Question to ask', 'Read', 'Read blog post', 'Read entire file', 'Read images and PDFs', 'RemoteTrigger', 'Rename a variable', 'Replacement text', 'Run npm install', 'Search query', 'SendMessage', 'Skill', 'SlashCommand', 'Stop a running task', 'Task', 'Task ID', 'Task ID to retrieve', 'Task ID to stop', 'Task description', 'Task subject', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'Text to replace', 'TodoWrite', 'ToolSearch', 'WebFetch', 'WebSearch', 'Write', 'agent', 'allowed_domains', 'bash_id', 'blocked_domains', 'branch', 'cell_id', 'cell_type', 'code or markdown', 'command', 'content', 'cronId', 'description', 'discovery', 'edit_mode', 'file', 'file_path', 'filter', 'git', 'glob', 'global_allowed', 'global_disallowed', 'interaction', 'isolation', 'limit', 'message', 'model', 'new_source', 'new_string', 'notebook_path', 'offset', 'old_string', 'options', 'output_mode', 'path', 'pattern', 'planning', 'productivity', 'prompt', 'query', 'question', 'replace_all', 'run_in_background', 'schedule', 'scheduling', 'session_configs', 'shell_id', 'status', 'subagent_type', 'subject', 'system', 'task', 'taskId', 'timeout', 'to', 'todos', 'tool_categories', 'total_tools', 'trigger', 'url', 'web'] \ No newline at end of file diff --git a/.hypothesis/constants/49266abea451322c b/.hypothesis/constants/49266abea451322c deleted file mode 100644 index 86ecf9b..0000000 --- a/.hypothesis/constants/49266abea451322c +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/models.py -# hypothesis_version: 6.151.4 - -[0.3, 0.5, 0.7, 0.9, 1.0, 1.5, 100, 200, 500, 4096, '-_.', 'System prompt', 'after', 'assistant', 'chat.completion', 'command', 'content_filter', 'end_turn', 'json_object', 'length', 'max_thinking_tokens', 'max_tokens', 'message', 'model', 'n', 'name', 'null', 'server_name', 'stop', 'stop_sequence', 'system', 'text', 'tool_name', 'type', 'user'] \ No newline at end of file diff --git a/.hypothesis/constants/4bfa246f2ad136a7 b/.hypothesis/constants/4bfa246f2ad136a7 deleted file mode 100644 index cc6d3ab..0000000 --- a/.hypothesis/constants/4bfa246f2ad136a7 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/claude_cli.py -# hypothesis_version: 6.151.4 - -[0.0, 1000, 600000, 'Hello', '_', '__dict__', 'assistant', 'claude_code', 'completion_tokens', 'content', 'data', 'duration_ms', 'error_message', 'init', 'is_error', 'message', 'model', 'num_turns', 'preset', 'prompt_tokens', 'result', 'session_id', 'subtype', 'success', 'system', 'text', 'total_cost_usd', 'total_tokens', 'type'] \ No newline at end of file diff --git a/.hypothesis/constants/4ff5447358cce36d b/.hypothesis/constants/4ff5447358cce36d deleted file mode 100644 index 409771b..0000000 --- a/.hypothesis/constants/4ff5447358cce36d +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/message_adapter.py -# hypothesis_version: 6.151.4 - -[200, '"', '.*?', '.*?', 'Here is the JSON:', 'Here is the data:', 'Here is the output:', 'Here is the result:', 'Here is your JSON:', "Here's the JSON:", "Here's the data:", "Here's the output:", "Here's the response:", "Here's the result:", "Here's your JSON:", 'JSON response:', 'Output:', 'Response:', 'Result:', 'The JSON is:', '[', '[]', '\\', '\\n\\s*\\n\\s*\\n', ']', '```', '```\n', '```\r\n', '```json\n', '```json\r\n', 'assistant', 'brace_match', 'code_block', 'content', 'direct', 'extracted_length', 'failed', 'fallback', 'fallback_used', 'fallback_value', 'finish_reason', 'method', 'model', 'original_length', 'preamble_found', 'preamble_removed', 'role', 'stop', 'strict_mode', 'success', 'system', 'user', '{', '{[', '}', '}]'] \ No newline at end of file diff --git a/.hypothesis/constants/5a015d1988280896 b/.hypothesis/constants/5a015d1988280896 deleted file mode 100644 index ea9c0b9..0000000 --- a/.hypothesis/constants/5a015d1988280896 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/function_calling.py -# hypothesis_version: 6.151.4 - -['No description', 'arguments', 'assistant', 'content', 'description', 'function', 'id', 'name', 'none', 'parameters', 'required', 'role', 'tool', 'tool_call_id', 'tool_calls', 'type', 'unknown', 'user', '{}'] \ No newline at end of file diff --git a/.hypothesis/constants/5eace2102a943108 b/.hypothesis/constants/5eace2102a943108 deleted file mode 100644 index 3174e75..0000000 --- a/.hypothesis/constants/5eace2102a943108 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/__init__.py -# hypothesis_version: 6.151.4 - -['2.5.1'] \ No newline at end of file diff --git a/.hypothesis/constants/5ec5250a39fbf461 b/.hypothesis/constants/5ec5250a39fbf461 deleted file mode 100644 index 1ae5b0f..0000000 --- a/.hypothesis/constants/5ec5250a39fbf461 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py -# hypothesis_version: 6.151.4 - -[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'Not Connected', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'effort', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'thinking', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/5ecb8d27c15539fb b/.hypothesis/constants/5ecb8d27c15539fb deleted file mode 100644 index 27b2349..0000000 --- a/.hypothesis/constants/5ecb8d27c15539fb +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/rate_limiter.py -# hypothesis_version: 6.151.4 - -[429, '/', '1', '10/minute', '15/minute', '2/minute', '30', '30/minute', 'RATE_LIMIT_ENABLED', 'Retry-After', 'auth', 'chat', 'code', 'debug', 'error', 'general', 'health', 'message', 'on', 'rate_limit_exceeded', 'retry_after', 'session', 'too_many_requests', 'true', 'type', 'yes'] \ No newline at end of file diff --git a/.hypothesis/constants/5f1ff972bb16d351 b/.hypothesis/constants/5f1ff972bb16d351 deleted file mode 100644 index e194158..0000000 --- a/.hypothesis/constants/5f1ff972bb16d351 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/model_service.py -# hypothesis_version: 6.151.4 - -[10.0, 200, 401, 429, '2023-06-01', 'ANTHROPIC_API_KEY', 'anthropic', 'anthropic-version', 'api', 'auth_method', 'bedrock', 'claude_cli', 'count', 'current_count', 'data', 'fallback', 'id', 'initialized', 'last_refresh', 'message', 'model_count', 'models', 'source', 'success', 'vertex', 'x-api-key'] \ No newline at end of file diff --git a/.hypothesis/constants/5fa7e22095c251de b/.hypothesis/constants/5fa7e22095c251de deleted file mode 100644 index b3a9add..0000000 --- a/.hypothesis/constants/5fa7e22095c251de +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/__init__.py -# hypothesis_version: 6.151.4 - -['2.6.0'] \ No newline at end of file diff --git a/.hypothesis/constants/62961dda076a1c18 b/.hypothesis/constants/62961dda076a1c18 deleted file mode 100644 index 5db8079..0000000 --- a/.hypothesis/constants/62961dda076a1c18 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/model_service.py -# hypothesis_version: 6.151.4 - -[10.0, 200, 401, 429, '2023-06-01', 'ANTHROPIC_API_KEY', 'anthropic-version', 'api', 'count', 'current_count', 'data', 'fallback', 'id', 'initialized', 'last_refresh', 'message', 'model_count', 'models', 'source', 'success', 'x-api-key'] \ No newline at end of file diff --git a/.hypothesis/constants/6720b331c8de2f4d b/.hypothesis/constants/6720b331c8de2f4d deleted file mode 100644 index a7c8a25..0000000 --- a/.hypothesis/constants/6720b331c8de2f4d +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/function_calling.py -# hypothesis_version: 6.151.4 - -['No description', '\\[\\s*\\{\\s*"name"\\s*:', ']', 'arguments', 'assistant', 'content', 'description', 'function', 'name', 'none', 'parameters', 'required', 'role', 'tool', 'tool_call_id', 'tool_calls', 'unknown', 'user', '{}'] \ No newline at end of file diff --git a/.hypothesis/constants/6945d5fe75d7baf9 b/.hypothesis/constants/6945d5fe75d7baf9 deleted file mode 100644 index ba7699f..0000000 --- a/.hypothesis/constants/6945d5fe75d7baf9 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/constants.py -# hypothesis_version: 6.151.4 - -[100, 200, 8000, 600000, 'Bash', 'BashOutput', 'DEFAULT_MODEL', 'Edit', 'Glob', 'Grep', 'KillShell', 'NotebookEdit', 'Read', 'Skill', 'SlashCommand', 'Task', 'TodoWrite', 'WebFetch', 'WebSearch', 'Write', 'claude_code', 'preset', 'text'] \ No newline at end of file diff --git a/.hypothesis/constants/6a1bdddafd3867b0 b/.hypothesis/constants/6a1bdddafd3867b0 deleted file mode 100644 index bf90daa..0000000 --- a/.hypothesis/constants/6a1bdddafd3867b0 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py -# hypothesis_version: 6.151.4 - -[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Disconnected', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'effort', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'json_schema', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'thinking', 'tool_calls', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/6c388abca123fca7 b/.hypothesis/constants/6c388abca123fca7 deleted file mode 100644 index 799af7c..0000000 --- a/.hypothesis/constants/6c388abca123fca7 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/__init__.py -# hypothesis_version: 6.151.4 - -['2.4.0'] \ No newline at end of file diff --git a/.hypothesis/constants/6f4af6e3fb4bf935 b/.hypothesis/constants/6f4af6e3fb4bf935 deleted file mode 100644 index 5444ddc..0000000 --- a/.hypothesis/constants/6f4af6e3fb4bf935 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/.venv/bin/pytest -# hypothesis_version: 6.151.4 - -['__main__'] \ No newline at end of file diff --git a/.hypothesis/constants/79a494eefa2125eb b/.hypothesis/constants/79a494eefa2125eb deleted file mode 100644 index 55bf8e2..0000000 --- a/.hypothesis/constants/79a494eefa2125eb +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/tool_manager.py -# hypothesis_version: 6.151.4 - -['Agent', 'AskUserQuestion', 'Available choices', 'Bash', 'BashOutput', 'Command to run', 'Create a new file', 'CronCreate', 'CronDelete', 'CronList', 'Delete notebook cell', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'Execute git status', 'ExitPlanMode', 'ExitWorktree', 'Find TODO comments', 'Glob', 'Grep', 'ID of cell to edit', 'KillShell', 'List all tasks', 'Message content', 'New cell content', 'New status', 'NotebookEdit', 'Path to .ipynb file', 'Question to ask', 'Read', 'Read blog post', 'Read entire file', 'Read images and PDFs', 'RemoteTrigger', 'Rename a variable', 'Replacement text', 'Run npm install', 'Search query', 'SendMessage', 'Skill', 'SlashCommand', 'Stop a running task', 'Task', 'Task ID', 'Task ID to retrieve', 'Task ID to stop', 'Task description', 'Task subject', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'Text to replace', 'TodoWrite', 'ToolSearch', 'WebFetch', 'WebSearch', 'Write', 'agent', 'allowed_domains', 'bash_id', 'blocked_domains', 'branch', 'cell_id', 'cell_type', 'code or markdown', 'command', 'content', 'cronId', 'description', 'discovery', 'edit_mode', 'file', 'file_path', 'filter', 'git', 'glob', 'global_allowed', 'global_disallowed', 'interaction', 'isolation', 'limit', 'message', 'model', 'new_source', 'new_string', 'notebook_path', 'offset', 'old_string', 'options', 'output_mode', 'path', 'pattern', 'planning', 'productivity', 'prompt', 'query', 'question', 'replace_all', 'run_in_background', 'schedule', 'scheduling', 'session_configs', 'shell_id', 'status', 'subagent_type', 'subject', 'system', 'task', 'taskId', 'timeout', 'to', 'todos', 'tool_categories', 'total_tools', 'trigger', 'url', 'web'] \ No newline at end of file diff --git a/.hypothesis/constants/7c2b91b3ea4d5bae b/.hypothesis/constants/7c2b91b3ea4d5bae deleted file mode 100644 index 409771b..0000000 --- a/.hypothesis/constants/7c2b91b3ea4d5bae +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/message_adapter.py -# hypothesis_version: 6.151.4 - -[200, '"', '.*?', '.*?', 'Here is the JSON:', 'Here is the data:', 'Here is the output:', 'Here is the result:', 'Here is your JSON:', "Here's the JSON:", "Here's the data:", "Here's the output:", "Here's the response:", "Here's the result:", "Here's your JSON:", 'JSON response:', 'Output:', 'Response:', 'Result:', 'The JSON is:', '[', '[]', '\\', '\\n\\s*\\n\\s*\\n', ']', '```', '```\n', '```\r\n', '```json\n', '```json\r\n', 'assistant', 'brace_match', 'code_block', 'content', 'direct', 'extracted_length', 'failed', 'fallback', 'fallback_used', 'fallback_value', 'finish_reason', 'method', 'model', 'original_length', 'preamble_found', 'preamble_removed', 'role', 'stop', 'strict_mode', 'success', 'system', 'user', '{', '{[', '}', '}]'] \ No newline at end of file diff --git a/.hypothesis/constants/7cbb728ba70d01ef b/.hypothesis/constants/7cbb728ba70d01ef deleted file mode 100644 index d4ca8b0..0000000 --- a/.hypothesis/constants/7cbb728ba70d01ef +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/retry.py -# hypothesis_version: 6.151.4 - -[0.25, 400, 401, 429, 500, 529, 1000, 30000, 'connection', 'econnreset', 'epipe', 'timeout'] \ No newline at end of file diff --git a/.hypothesis/constants/8147e68ddedfd20b b/.hypothesis/constants/8147e68ddedfd20b deleted file mode 100644 index cf5690a..0000000 --- a/.hypothesis/constants/8147e68ddedfd20b +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/constants.py -# hypothesis_version: 6.151.4 - -[100, 200, 8000, 600000, 'Bash', 'BashOutput', 'DEFAULT_MODEL', 'Edit', 'Glob', 'Grep', 'KillShell', 'NotebookEdit', 'Read', 'Skill', 'SlashCommand', 'Task', 'TodoWrite', 'WebFetch', 'WebSearch', 'Write', 'claude-opus-4-6', 'claude_code', 'preset', 'text'] \ No newline at end of file diff --git a/.hypothesis/constants/8c6b3f1674b9e0fe b/.hypothesis/constants/8c6b3f1674b9e0fe deleted file mode 100644 index 487aaa6..0000000 --- a/.hypothesis/constants/8c6b3f1674b9e0fe +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/__init__.py -# hypothesis_version: 6.151.4 - -['2.4.2'] \ No newline at end of file diff --git a/.hypothesis/constants/92d90c488a56ada0 b/.hypothesis/constants/92d90c488a56ada0 deleted file mode 100644 index 4ab6278..0000000 --- a/.hypothesis/constants/92d90c488a56ada0 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/constants.py -# hypothesis_version: 6.151.4 - -[0.01, 0.08, 0.1, 0.3, 0.5, 0.8, 1.0, 1.25, 1.5, 3.0, 3.75, 4.0, 5.0, 6.25, 15.0, 18.75, 25.0, 75.0, 100, 200, 8000, 8192, 32000, 64000, 128000, 200000, 600000, 'Agent', 'AskUserQuestion', 'Bash', 'BashOutput', 'CronCreate', 'CronDelete', 'CronList', 'DEFAULT_MODEL', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'ExitPlanMode', 'ExitWorktree', 'Glob', 'Grep', 'KillShell', 'NotebookEdit', 'Read', 'RemoteTrigger', 'SendMessage', 'Skill', 'SlashCommand', 'Task', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'TodoWrite', 'ToolSearch', 'WebFetch', 'WebSearch', 'Write', 'adaptive', 'cache_read', 'cache_write', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude_code', 'context_window', 'default_max_output', 'disabled', 'enabled', 'high', 'input', 'low', 'max', 'max_output_limit', 'medium', 'output', 'preset', 'text'] \ No newline at end of file diff --git a/.hypothesis/constants/9adb793441356481 b/.hypothesis/constants/9adb793441356481 deleted file mode 100644 index dcbc306..0000000 --- a/.hypothesis/constants/9adb793441356481 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/tool_manager.py -# hypothesis_version: 6.151.4 - -['Bash', 'BashOutput', 'Create a new file', 'Delete notebook cell', 'Edit', 'Execute git status', 'Find TODO comments', 'Glob', 'Grep', 'ID of cell to edit', 'KillShell', 'New cell content', 'NotebookEdit', 'Path to .ipynb file', 'Read', 'Read blog post', 'Read entire file', 'Read images and PDFs', 'Rename a variable', 'Replacement text', 'Run npm install', 'Search query', 'Skill', 'SlashCommand', 'Task', 'Text to replace', 'TodoWrite', 'WebFetch', 'WebSearch', 'Write', 'agent', 'allowed_domains', 'bash_id', 'blocked_domains', 'cell_id', 'cell_type', 'code or markdown', 'command', 'content', 'description', 'edit_mode', 'file', 'file_path', 'filter', 'glob', 'global_allowed', 'global_disallowed', 'limit', 'new_source', 'new_string', 'notebook_path', 'offset', 'old_string', 'output_mode', 'path', 'pattern', 'productivity', 'prompt', 'query', 'replace_all', 'run_in_background', 'session_configs', 'shell_id', 'subagent_type', 'system', 'timeout', 'todos', 'tool_categories', 'total_tools', 'url', 'web'] \ No newline at end of file diff --git a/.hypothesis/constants/a282b0de12e1165d b/.hypothesis/constants/a282b0de12e1165d deleted file mode 100644 index 4b9add5..0000000 --- a/.hypothesis/constants/a282b0de12e1165d +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py -# hypothesis_version: 6.151.4 - -[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Disconnected', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'effort', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'thinking', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/a560162a0935d261 b/.hypothesis/constants/a560162a0935d261 deleted file mode 100644 index b04e961..0000000 --- a/.hypothesis/constants/a560162a0935d261 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/__init__.py -# hypothesis_version: 6.151.4 - -['2.5.2'] \ No newline at end of file diff --git a/.hypothesis/constants/addbf4cc0fd2c0d3 b/.hypothesis/constants/addbf4cc0fd2c0d3 deleted file mode 100644 index 0bbc84e..0000000 --- a/.hypothesis/constants/addbf4cc0fd2c0d3 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/parameter_validator.py -# hypothesis_version: 6.151.4 - -[1.0, 100, 50000, ',', 'acceptEdits', 'allowed_tools', 'bypassPermissions', 'default', 'disallowed_tools', 'frequency_penalty', 'logit_bias', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'messages', 'model', 'n', 'permission_mode', 'plan', 'presence_penalty', 'response_format', 'stop', 'stream', 'suggestions', 'supported_parameters', 'temperature', 'top_p', 'user (for logging)', 'warnings', 'x-claude-max-turns'] \ No newline at end of file diff --git a/.hypothesis/constants/b04074d551450985 b/.hypothesis/constants/b04074d551450985 deleted file mode 100644 index cc6d3ab..0000000 --- a/.hypothesis/constants/b04074d551450985 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/claude_cli.py -# hypothesis_version: 6.151.4 - -[0.0, 1000, 600000, 'Hello', '_', '__dict__', 'assistant', 'claude_code', 'completion_tokens', 'content', 'data', 'duration_ms', 'error_message', 'init', 'is_error', 'message', 'model', 'num_turns', 'preset', 'prompt_tokens', 'result', 'session_id', 'subtype', 'success', 'system', 'text', 'total_cost_usd', 'total_tokens', 'type'] \ No newline at end of file diff --git a/.hypothesis/constants/b557a9a709d4c7cf b/.hypothesis/constants/b557a9a709d4c7cf deleted file mode 100644 index 65877f5..0000000 --- a/.hypothesis/constants/b557a9a709d4c7cf +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/auth.py -# hypothesis_version: 6.151.4 - -[401, '1', 'ANTHROPIC_API_KEY', 'API_KEY', 'AWS_ACCESS_KEY_ID', 'AWS_DEFAULT_REGION', 'AWS_REGION', 'Bearer', 'CLAUDE_AUTH_METHOD', 'CLOUD_ML_REGION', 'Invalid API key', 'Missing API key', 'WWW-Authenticate', 'anthropic', 'api_key', 'api_key_length', 'api_key_present', 'aws_region', 'bedrock', 'claude_cli', 'cli', 'config', 'errors', 'method', 'note', 'project_id', 'region', 'runtime_api_key', 'status', 'valid', 'vertex'] \ No newline at end of file diff --git a/.hypothesis/constants/ba3ef7c1e31eb53a b/.hypothesis/constants/ba3ef7c1e31eb53a deleted file mode 100644 index a053b50..0000000 --- a/.hypothesis/constants/ba3ef7c1e31eb53a +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/cost_tracker.py -# hypothesis_version: 6.151.4 - -[0.0, 0.3, 3.0, 3.75, 15.0, 1000000, 'active_sessions', 'cache_read', 'cache_write', 'cost_usd', 'input', 'input_tokens', 'model_usage', 'output', 'output_tokens', 'request_count', 'requests', 'session_id', 'total_cost_usd', 'total_input_tokens', 'total_output_tokens', 'total_requests'] \ No newline at end of file diff --git a/.hypothesis/constants/bd1bff39ca7e3f9f b/.hypothesis/constants/bd1bff39ca7e3f9f deleted file mode 100644 index 0798c20..0000000 --- a/.hypothesis/constants/bd1bff39ca7e3f9f +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py -# hypothesis_version: 6.151.4 - -[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'Not Connected', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/c2ebc0a232bcf5ab b/.hypothesis/constants/c2ebc0a232bcf5ab deleted file mode 100644 index 11a4c00..0000000 --- a/.hypothesis/constants/c2ebc0a232bcf5ab +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/claude_cli.py -# hypothesis_version: 6.151.4 - -[0.0, 1000, 600000, 'Hello', '_', '__dict__', 'assistant', 'claude_code', 'completion_tokens', 'content', 'data', 'duration_ms', 'error_message', 'init', 'is_error', 'message', 'model', 'num_turns', 'preset', 'prompt_tokens', 'result', 'session_id', 'status_code', 'subtype', 'success', 'system', 'text', 'total_cost_usd', 'total_tokens', 'type'] \ No newline at end of file diff --git a/.hypothesis/constants/c48321436c435109 b/.hypothesis/constants/c48321436c435109 deleted file mode 100644 index b05a508..0000000 --- a/.hypothesis/constants/c48321436c435109 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/cpu_watchdog.py -# hypothesis_version: 6.151.4 - -[0.0, 100.0, '/proc/self/stat', '3', '30', '80', 'CPU watchdog stopped', 'SC_CLK_TCK', 'WATCHDOG_ENABLED', 'WATCHDOG_INTERVAL', 'WATCHDOG_STRIKES', 'false', 'linux', 'true'] \ No newline at end of file diff --git a/.hypothesis/constants/c6b66dd364db4aea b/.hypothesis/constants/c6b66dd364db4aea deleted file mode 100644 index d4abd27..0000000 --- a/.hypothesis/constants/c6b66dd364db4aea +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py -# hypothesis_version: 6.151.4 - -[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'Not Connected', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'effort', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'thinking', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/cc377c555d1180c1 b/.hypothesis/constants/cc377c555d1180c1 deleted file mode 100644 index 0278c14..0000000 --- a/.hypothesis/constants/cc377c555d1180c1 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/message_adapter.py -# hypothesis_version: 6.151.4 - -[b'```\n', b'```\r\n', b'```json\n', b'```json\r\n', 200, '"', '.*?', '.*?', 'Here is the JSON:', 'Here is the data:', 'Here is the output:', 'Here is the result:', 'Here is your JSON:', "Here's the JSON:", "Here's the data:", "Here's the output:", "Here's the response:", "Here's the result:", "Here's your JSON:", 'JSON response:', 'Output:', 'Response:', 'Result:', 'The JSON is:', '[', '[]', '\\', '\\n\\s*\\n\\s*\\n', ']', '```', 'assistant', 'brace_match', 'code_block', 'content', 'direct', 'extracted_length', 'failed', 'fallback', 'fallback_used', 'fallback_value', 'finish_reason', 'method', 'model', 'original_length', 'preamble_found', 'preamble_removed', 'role', 'stop', 'strict_mode', 'success', 'system', 'user', '{', '{[', '}', '}]'] \ No newline at end of file diff --git a/.hypothesis/constants/cd8780436271eddb b/.hypothesis/constants/cd8780436271eddb deleted file mode 100644 index dc82bd3..0000000 --- a/.hypothesis/constants/cd8780436271eddb +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/constants.py -# hypothesis_version: 6.151.4 - -[0.01, 0.08, 0.1, 0.3, 0.5, 0.8, 1.0, 1.25, 1.5, 3.0, 3.75, 4.0, 5.0, 6.25, 15.0, 18.75, 25.0, 75.0, 100, 200, 8000, 8192, 32000, 64000, 128000, 200000, 600000, 'Agent', 'AskUserQuestion', 'Bash', 'Brief', 'Config', 'CronCreate', 'CronDelete', 'CronList', 'DEFAULT_MODEL', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'ExitPlanMode', 'ExitWorktree', 'Glob', 'Grep', 'ListMcpResources', 'ListPeers', 'Monitor', 'NotebookEdit', 'PushNotification', 'REPL', 'Read', 'ReadMcpResource', 'RemoteTrigger', 'SendMessage', 'SendUserFile', 'Skill', 'Sleep', 'Task', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'TodoWrite', 'ToolSearch', 'VerifyPlanExecution', 'WebFetch', 'WebSearch', 'Write', 'adaptive', 'cache_read', 'cache_write', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude_code', 'context_window', 'default_max_output', 'disabled', 'enabled', 'high', 'input', 'low', 'max', 'max_output_limit', 'medium', 'output', 'preset', 'text'] \ No newline at end of file diff --git a/.hypothesis/constants/cfb85dbb9b5d85a6 b/.hypothesis/constants/cfb85dbb9b5d85a6 deleted file mode 100644 index 0798c20..0000000 --- a/.hypothesis/constants/cfb85dbb9b5d85a6 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py -# hypothesis_version: 6.151.4 - -[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'Not Connected', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/d14c45ee4f738a0e b/.hypothesis/constants/d14c45ee4f738a0e deleted file mode 100644 index 09b4faf..0000000 --- a/.hypothesis/constants/d14c45ee4f738a0e +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/session_manager.py -# hypothesis_version: 6.151.4 - -['active_sessions', 'expired_sessions', 'total_messages'] \ No newline at end of file diff --git a/.hypothesis/constants/d434e96105f62824 b/.hypothesis/constants/d434e96105f62824 deleted file mode 100644 index 869fce1..0000000 --- a/.hypothesis/constants/d434e96105f62824 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/cost_tracker.py -# hypothesis_version: 6.151.4 - -[0.0, 0.3, 3.0, 3.75, 15.0, 1000000, 'active_sessions', 'cache_read', 'cache_write', 'claude-sonnet-4-6', 'cost_usd', 'input', 'input_tokens', 'model_usage', 'output', 'output_tokens', 'request_count', 'requests', 'session_id', 'total_cost_usd', 'total_input_tokens', 'total_output_tokens', 'total_requests'] \ No newline at end of file diff --git a/.hypothesis/constants/d834e79418fe5fa5 b/.hypothesis/constants/d834e79418fe5fa5 deleted file mode 100644 index 4ab6278..0000000 --- a/.hypothesis/constants/d834e79418fe5fa5 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/constants.py -# hypothesis_version: 6.151.4 - -[0.01, 0.08, 0.1, 0.3, 0.5, 0.8, 1.0, 1.25, 1.5, 3.0, 3.75, 4.0, 5.0, 6.25, 15.0, 18.75, 25.0, 75.0, 100, 200, 8000, 8192, 32000, 64000, 128000, 200000, 600000, 'Agent', 'AskUserQuestion', 'Bash', 'BashOutput', 'CronCreate', 'CronDelete', 'CronList', 'DEFAULT_MODEL', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'ExitPlanMode', 'ExitWorktree', 'Glob', 'Grep', 'KillShell', 'NotebookEdit', 'Read', 'RemoteTrigger', 'SendMessage', 'Skill', 'SlashCommand', 'Task', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'TodoWrite', 'ToolSearch', 'WebFetch', 'WebSearch', 'Write', 'adaptive', 'cache_read', 'cache_write', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude_code', 'context_window', 'default_max_output', 'disabled', 'enabled', 'high', 'input', 'low', 'max', 'max_output_limit', 'medium', 'output', 'preset', 'text'] \ No newline at end of file diff --git a/.hypothesis/constants/d84afc418365a945 b/.hypothesis/constants/d84afc418365a945 deleted file mode 100644 index 7514e7d..0000000 --- a/.hypothesis/constants/d84afc418365a945 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/tool_manager.py -# hypothesis_version: 6.151.4 - -['Agent', 'AskUserQuestion', 'Available choices', 'Bash', 'Brief', 'Code to execute', 'Command to run', 'Config', 'Config key', 'Config value', 'Create a new file', 'CronCreate', 'CronDelete', 'CronList', 'Delete notebook cell', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'Execute git status', 'ExitPlanMode', 'ExitWorktree', 'Find TODO comments', 'Glob', 'Grep', 'ID of cell to edit', 'List all tasks', 'ListMcpResources', 'ListPeers', 'MCP server name', 'Message content', 'Monitor', 'New cell content', 'New status', 'NotebookEdit', 'Notification body', 'Notification title', 'Optional arguments', 'Path to .ipynb file', 'Programming language', 'PushNotification', 'Question to ask', 'REPL', 'Read', 'Read blog post', 'Read current config', 'Read entire file', 'Read images and PDFs', 'ReadMcpResource', 'RemoteTrigger', 'Rename a variable', 'Replacement text', 'Resource URI', 'Run commit skill', 'Run npm install', 'Search query', 'SendMessage', 'SendUserFile', 'Skill', 'Sleep', 'Stop a running task', 'Task', 'Task ID', 'Task ID to retrieve', 'Task ID to stop', 'Task description', 'Task subject', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'Text to replace', 'TodoWrite', 'ToolSearch', 'Update a setting', 'Verbosity level', 'VerifyPlanExecution', 'WebFetch', 'WebSearch', 'Write', 'action', 'agent', 'allowed_domains', 'args', 'blocked_domains', 'body', 'branch', 'cell_id', 'cell_type', 'code', 'code or markdown', 'command', 'content', 'cronId', 'description', 'discovery', 'duration', 'edit_mode', 'file', 'file_path', 'git', 'glob', 'global_allowed', 'global_disallowed', 'interaction', 'isolation', 'key', 'language', 'level', 'limit', 'mcp', 'message', 'model', 'new_source', 'new_string', 'notebook_path', 'notification', 'offset', 'old_string', 'options', 'output', 'output_mode', 'path', 'pattern', 'plan_id', 'planning', 'productivity', 'prompt', 'query', 'question', 'read or write', 'replace_all', 'run_in_background', 'schedule', 'scheduling', 'server', 'session_configs', 'skill', 'status', 'subagent_type', 'subject', 'system', 'target', 'task', 'taskId', 'timeout', 'title', 'to', 'todos', 'tool_categories', 'total_tools', 'trigger', 'uri', 'url', 'value', 'web'] \ No newline at end of file diff --git a/.hypothesis/constants/db4f54cef59f98f2 b/.hypothesis/constants/db4f54cef59f98f2 deleted file mode 100644 index cff5fe4..0000000 --- a/.hypothesis/constants/db4f54cef59f98f2 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py -# hypothesis_version: 6.151.4 - -[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'Not Connected', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/dea42edc03d45162 b/.hypothesis/constants/dea42edc03d45162 deleted file mode 100644 index 64e376d..0000000 --- a/.hypothesis/constants/dea42edc03d45162 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/function_calling.py -# hypothesis_version: 6.151.4 - -['No description', 'arguments', 'assistant', 'content', 'description', 'function', 'name', 'none', 'parameters', 'required', 'role', 'tool', 'tool_call_id', 'tool_calls', 'unknown', 'user', '{}'] \ No newline at end of file diff --git a/.hypothesis/constants/eb715738993787bc b/.hypothesis/constants/eb715738993787bc deleted file mode 100644 index b05a508..0000000 --- a/.hypothesis/constants/eb715738993787bc +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/cpu_watchdog.py -# hypothesis_version: 6.151.4 - -[0.0, 100.0, '/proc/self/stat', '3', '30', '80', 'CPU watchdog stopped', 'SC_CLK_TCK', 'WATCHDOG_ENABLED', 'WATCHDOG_INTERVAL', 'WATCHDOG_STRIKES', 'false', 'linux', 'true'] \ No newline at end of file diff --git a/.hypothesis/constants/f070aebf9a1fa192 b/.hypothesis/constants/f070aebf9a1fa192 deleted file mode 100644 index abb0d2d..0000000 --- a/.hypothesis/constants/f070aebf9a1fa192 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/__init__.py -# hypothesis_version: 6.151.4 - -['2.5.0'] \ No newline at end of file diff --git a/.hypothesis/constants/f0942b966cd1a673 b/.hypothesis/constants/f0942b966cd1a673 deleted file mode 100644 index bf90daa..0000000 --- a/.hypothesis/constants/f0942b966cd1a673 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/main.py -# hypothesis_version: 6.151.4 - -[30.0, 400, 404, 413, 422, 429, 500, 503, 1000, 1024, 8000, 100000, ' Example usage:', ' -> ', '#22c55e', '#ef4444', '*', '-_', '/', '/health', '/v1/', '/v1/auth/status', '/v1/cache/clear', '/v1/cache/stats', '/v1/chat/completions', '/v1/compatibility', '/v1/debug/request', '/v1/mcp/connect', '/v1/mcp/disconnect', '/v1/mcp/servers', '/v1/mcp/stats', '/v1/messages', '/v1/models', '/v1/models/refresh', '/v1/models/status', '/v1/sessions', '/v1/sessions/stats', '/v1/tools', '/v1/tools/config', '/v1/tools/stats', '/version', '0.0.0.0', '1', '1.0.0', '127.0.0.1', '600000', '8000', '=', 'API_KEY', 'CLAUDE_CWD', 'CLAUDE_WRAPPER_HOST', 'CORS_ORIGINS', 'Cache-Control', 'Connected', 'Connection', 'DEBUG_MODE', 'Disconnected', 'Hello, world!', 'MAX_REQUEST_SIZE', 'MAX_TIMEOUT', 'PORT', 'POST', 'Session not found', 'Unknown error', 'VERBOSE', 'X-Claude-Max-Turns', 'X-Enable-Cache', 'X-Request-ID', '["*"]', '[]', '__main__', 'allowed_tools', 'anthropic', 'api_error', 'api_key_required', 'api_key_source', 'api_version', 'assistant', 'auth', 'bypassPermissions', 'chat', 'claude_code_auth', 'code', 'common_issues', 'compatibility_report', 'completion_tokens', 'content', 'content-length', 'custom_headers', 'cwd', 'data', 'data: [DONE]\n\n', 'debug', 'debug_info', 'debug_mode_enabled', 'debug_tip', 'default_ttl_hours', 'details', 'disallowed_tools', 'effort', 'end_turn', 'entries_cleared', 'environment', 'error', 'errors', 'false', 'field', 'general', 'headers', 'health', 'healthy', 'help', 'id', 'input', 'json_object', 'json_parse_error', 'json_schema', 'keep-alive', 'list', 'loc', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'message', 'messages', 'method', 'model', 'msg', 'n', 'no', 'no-cache', 'none', 'object', 'on', 'owned_by', 'parsed_body', 'permission_mode', 'prompt', 'prompt_tokens', 'prompts', 'raw_body', 'raw_request_body', 'request_id', 'request_too_large', 'resources', 'resume', 'role', 'runtime', 'server_info', 'service', 'session_stats', 'status', 'stop', 'stream', 'streaming_error', 'supported', 'system_prompt', 'text', 'text/event-stream', 'thinking', 'tool_calls', 'tools', 'total_tokens', 'true', 'type', 'unknown', 'url', 'user', 'v1', 'valid', 'validated_data', 'validation_error', 'validation_result', 'version', 'y', 'yes', '🔑 API Key Generated!'] \ No newline at end of file diff --git a/.hypothesis/constants/f102fa85cdaff8e2 b/.hypothesis/constants/f102fa85cdaff8e2 deleted file mode 100644 index d28e0d8..0000000 --- a/.hypothesis/constants/f102fa85cdaff8e2 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/mcp_client.py -# hypothesis_version: 6.151.4 - -['arguments', 'connected', 'connected_servers', 'description', 'enabled', 'inputSchema', 'input_schema', 'mcp_available', 'mimeType', 'name', 'prompts', 'registered_servers', 'resources', 'servers', 'tools', 'total_prompts', 'total_resources', 'total_tools', 'uri'] \ No newline at end of file diff --git a/.hypothesis/constants/f421bd7fea970ca8 b/.hypothesis/constants/f421bd7fea970ca8 deleted file mode 100644 index 769feb3..0000000 --- a/.hypothesis/constants/f421bd7fea970ca8 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/parameter_validator.py -# hypothesis_version: 6.151.4 - -[1.0, 100, 50000, ',', 'acceptEdits', 'allowed_tools', 'bypassPermissions', 'default', 'disallowed_tools', 'effort', 'frequency_penalty', 'logit_bias', 'max_output_limit', 'max_thinking_tokens', 'max_tokens', 'max_turns', 'messages', 'model', 'n', 'permission_mode', 'plan', 'presence_penalty', 'response_format', 'stop', 'stream', 'suggestions', 'supported_parameters', 'temperature', 'thinking', 'top_p', 'user (for logging)', 'warnings', 'x-claude-effort', 'x-claude-max-turns', 'x-claude-thinking'] \ No newline at end of file diff --git a/.hypothesis/constants/fb8091c3914026d9 b/.hypothesis/constants/fb8091c3914026d9 deleted file mode 100644 index e76431a..0000000 --- a/.hypothesis/constants/fb8091c3914026d9 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/request_cache.py -# hypothesis_version: 6.151.4 - -[100, '1', '100', '60', 'current_size', 'enabled', 'evictions', 'expirations', 'false', 'hit_rate_percent', 'hits', 'max_size', 'max_tokens', 'messages', 'misses', 'model', 'on', 'response_format', 'temperature', 'top_p', 'true', 'ttl_seconds', 'yes'] \ No newline at end of file diff --git a/.hypothesis/constants/fbd667538a3b64b4 b/.hypothesis/constants/fbd667538a3b64b4 deleted file mode 100644 index 869fce1..0000000 --- a/.hypothesis/constants/fbd667538a3b64b4 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/cost_tracker.py -# hypothesis_version: 6.151.4 - -[0.0, 0.3, 3.0, 3.75, 15.0, 1000000, 'active_sessions', 'cache_read', 'cache_write', 'claude-sonnet-4-6', 'cost_usd', 'input', 'input_tokens', 'model_usage', 'output', 'output_tokens', 'request_count', 'requests', 'session_id', 'total_cost_usd', 'total_input_tokens', 'total_output_tokens', 'total_requests'] \ No newline at end of file diff --git a/.hypothesis/constants/fe53ac5fa2ae2faf b/.hypothesis/constants/fe53ac5fa2ae2faf deleted file mode 100644 index 55bf8e2..0000000 --- a/.hypothesis/constants/fe53ac5fa2ae2faf +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/dkrachtus/repos/claude-code-openai-wrapper/src/tool_manager.py -# hypothesis_version: 6.151.4 - -['Agent', 'AskUserQuestion', 'Available choices', 'Bash', 'BashOutput', 'Command to run', 'Create a new file', 'CronCreate', 'CronDelete', 'CronList', 'Delete notebook cell', 'Edit', 'EnterPlanMode', 'EnterWorktree', 'Execute git status', 'ExitPlanMode', 'ExitWorktree', 'Find TODO comments', 'Glob', 'Grep', 'ID of cell to edit', 'KillShell', 'List all tasks', 'Message content', 'New cell content', 'New status', 'NotebookEdit', 'Path to .ipynb file', 'Question to ask', 'Read', 'Read blog post', 'Read entire file', 'Read images and PDFs', 'RemoteTrigger', 'Rename a variable', 'Replacement text', 'Run npm install', 'Search query', 'SendMessage', 'Skill', 'SlashCommand', 'Stop a running task', 'Task', 'Task ID', 'Task ID to retrieve', 'Task ID to stop', 'Task description', 'Task subject', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate', 'Text to replace', 'TodoWrite', 'ToolSearch', 'WebFetch', 'WebSearch', 'Write', 'agent', 'allowed_domains', 'bash_id', 'blocked_domains', 'branch', 'cell_id', 'cell_type', 'code or markdown', 'command', 'content', 'cronId', 'description', 'discovery', 'edit_mode', 'file', 'file_path', 'filter', 'git', 'glob', 'global_allowed', 'global_disallowed', 'interaction', 'isolation', 'limit', 'message', 'model', 'new_source', 'new_string', 'notebook_path', 'offset', 'old_string', 'options', 'output_mode', 'path', 'pattern', 'planning', 'productivity', 'prompt', 'query', 'question', 'replace_all', 'run_in_background', 'schedule', 'scheduling', 'session_configs', 'shell_id', 'status', 'subagent_type', 'subject', 'system', 'task', 'taskId', 'timeout', 'to', 'todos', 'tool_categories', 'total_tools', 'trigger', 'url', 'web'] \ No newline at end of file diff --git a/.hypothesis/unicode_data/14.0.0/charmap.json.gz b/.hypothesis/unicode_data/14.0.0/charmap.json.gz deleted file mode 100644 index d0054c610a618e7b0b411610e5ec51c7c134365e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21505 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 From c24dab7e8e48013b291318bd3639afc423961c35 Mon Sep 17 00:00:00 2001 From: ttlequals0 Date: Thu, 16 Apr 2026 19:19:39 -0400 Subject: [PATCH 17/38] =?UTF-8?q?feat=1B[38;2;102;102;102m:=1B[39m=1B[38;2?= =?UTF-8?q?;187;187;187m=20=1B[39mrefresh=1B[38;2;187;187;187m=20=1B[39msu?= =?UTF-8?q?pported=1B[38;2;187;187;187m=20=1B[39mmodel=1B[38;2;187;187;187?= =?UTF-8?q?m=20=1B[39mlist=1B[38;2;102;102;102m,=1B[39m=1B[38;2;187;187;18?= =?UTF-8?q?7m=20=1B[39madd=1B[38;2;187;187;187m=20=1B[39mClaude=1B[38;2;18?= =?UTF-8?q?7;187;187m=20=1B[39mOpus=1B[38;2;187;187;187m=20=1B[39m=1B[38;2?= =?UTF-8?q?;102;102;102m4.7=1B[39m=1B[38;2;187;187;187m=20=1B[39m=1B[38;2;?= =?UTF-8?q?102;102;102m(=1B[39mv2=1B[38;2;102;102;102m.=1B[39m=1B[38;2;102?= =?UTF-8?q?;102;102m7.0=1B[39m=1B[38;2;102;102;102m)=1B[39m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align CLAUDE_MODELS, MODEL_METADATA, MODEL_PRICING, and MODEL_FALLBACK_MAP with the Anthropic models docs as of 2026-04-16. Remove three models already retired at the API and add the new flagship Opus 4.7. - Add claude-opus-4-7 (1M context, 128K max output, $5/$25 per MTok) - Remove retired: claude-3-7-sonnet-20250219, claude-3-5-sonnet-20241022,  claude-3-5-haiku-20241022 - Fix context window to 1M for opus-4-7, opus-4-6, sonnet-4-6 - Fix max output to 32K for opus-4-1-20250805 and opus-4-20250514 - Fix max output to 64K for sonnet-4-6 (synchronous Messages API) - Sync .env.example DEFAULT_MODEL with code default (claude-sonnet-4-6) - Update landing-page quickstart and debug example to claude-sonnet-4-6 --- .env.example | 2 +- CHANGELOG.md | 25 +++++++++++++++++++++++++ README.md | 42 ++++++++++++++++++++++-------------------- pyproject.toml | 2 +- src/__init__.py | 2 +- src/constants.py | 33 +++++++++++++++++++-------------- src/main.py | 6 +++--- 7 files changed, 72 insertions(+), 40 deletions(-) diff --git a/.env.example b/.env.example index 749c598..5b8b031 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,7 @@ CORS_ORIGINS=["*"] # Model Configuration # Default Claude model to use when none specified in request -DEFAULT_MODEL=claude-sonnet-4-5-20250929 +DEFAULT_MODEL=claude-sonnet-4-6 # Rate Limiting Configuration RATE_LIMIT_ENABLED=true diff --git a/CHANGELOG.md b/CHANGELOG.md index b0853c1..e4b41b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to the Claude Code OpenAI Wrapper project will be documented The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.7.0] - 2026-04-16 + +### Added + +- **Claude Opus 4.7** (`claude-opus-4-7`): new flagship model -- 1M token context window, 128K max output, $5/$25 per MTok, falls back to `claude-sonnet-4-6` on overload + +### Changed + +- **Model metadata corrections** (`src/constants.py`): aligned with Anthropic docs (`platform.claude.com/docs/en/about-claude/models/overview`) + - `claude-opus-4-6`: context window 200K -> 1M + - `claude-sonnet-4-6`: context window 200K -> 1M, max output 128K -> 64K (synchronous Messages API) + - `claude-opus-4-1-20250805`: max output 64K -> 32K + - `claude-opus-4-20250514`: max output 64K -> 32K +- **Default model example**: `.env.example` `DEFAULT_MODEL` now matches code default (`claude-sonnet-4-6`) +- **Landing page quickstart** (`src/main.py`): uses `claude-sonnet-4-6` instead of dated Sonnet 4.5 snapshot +- **Debug endpoint example**: `example_valid_request.model` updated from retired `claude-3-sonnet-20240229` to `claude-sonnet-4-6` + +### Removed + +- **Retired models** removed from `CLAUDE_MODELS`, `MODEL_METADATA`, `MODEL_PRICING`: + - `claude-3-7-sonnet-20250219` (retired 2026-02-19) + - `claude-3-5-sonnet-20241022` (retired 2025-10-28) + - `claude-3-5-haiku-20241022` (retired 2026-02-19) +- `_PRICING_HAIKU_35` constant (no remaining consumers) + ## [2.6.0] - 2026-04-02 ### Added diff --git a/README.md b/README.md index 31c9001..89b84e0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,15 @@ OpenAI API-compatible wrapper for Claude Code. Drop it in front of any OpenAI cl ## Version -**Current:** 2.6.0 +**Current:** 2.7.0 + +What's new in 2.7.0: +- Added Claude Opus 4.7 (`claude-opus-4-7`) as the new flagship model +- Removed retired models: `claude-3-7-sonnet-20250219`, `claude-3-5-sonnet-20241022`, `claude-3-5-haiku-20241022` +- Corrected context window to 1M for `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6` +- Corrected max output to 32K for `claude-opus-4-1-20250805` and `claude-opus-4-20250514` +- Corrected max output to 64K for `claude-sonnet-4-6` (synchronous Messages API) +- Synced `.env.example` `DEFAULT_MODEL` with code default (`claude-sonnet-4-6`) What's new in 2.6.0: - OpenAI function calling simulation (tools/tool_choice parameters) @@ -240,34 +248,28 @@ Claude-specific options via HTTP headers: ## Supported Models -Model IDs, context windows, and pricing pulled from the open-sourced Claude Code CLI. +Model IDs, context windows, and pricing are sourced from the Anthropic models docs (`platform.claude.com/docs/en/about-claude/models/overview`). -### Claude 4.6 (Latest) +### Latest | Model | Context | Max Output | Input $/MTok | Output $/MTok | |-------|---------|-----------|-------------|--------------| -| `claude-opus-4-6` | 200K | 128K | $5 | $25 | -| `claude-sonnet-4-6` (default) | 200K | 128K | $3 | $15 | +| `claude-opus-4-7` | 1M | 128K | $5 | $25 | +| `claude-sonnet-4-6` (default) | 1M | 64K | $3 | $15 | +| `claude-haiku-4-5-20251001` | 200K | 64K | $1 | $5 | -### Claude 4.5 +### Legacy (active, consider migrating) | Model | Context | Max Output | Input $/MTok | Output $/MTok | |-------|---------|-----------|-------------|--------------| +| `claude-opus-4-6` | 1M | 128K | $5 | $25 | | `claude-opus-4-5-20251101` | 200K | 64K | $5 | $25 | +| `claude-opus-4-1-20250805` | 200K | 32K | $15 | $75 | | `claude-sonnet-4-5-20250929` | 200K | 64K | $3 | $15 | -| `claude-haiku-4-5-20251001` | 200K | 64K | $1 | $5 | - -### Claude 4.1 / 4.0 -| Model | Context | Max Output | Input $/MTok | Output $/MTok | -|-------|---------|-----------|-------------|--------------| -| `claude-opus-4-1-20250805` | 200K | 64K | $15 | $75 | -| `claude-opus-4-20250514` | 200K | 64K | $15 | $75 | -| `claude-sonnet-4-20250514` | 200K | 64K | $3 | $15 | -### Claude 3.x -| Model | Context | Max Output | Input $/MTok | Output $/MTok | -|-------|---------|-----------|-------------|--------------| -| `claude-3-7-sonnet-20250219` | 200K | 64K | $3 | $15 | -| `claude-3-5-sonnet-20241022` | 200K | 8K | $3 | $15 | -| `claude-3-5-haiku-20241022` | 200K | 8K | $0.80 | $4 | +### Deprecated (retires 2026-06-15) +| Model | Context | Max Output | Input $/MTok | Output $/MTok | Replacement | +|-------|---------|-----------|-------------|--------------|-------------| +| `claude-sonnet-4-20250514` | 200K | 64K | $3 | $15 | `claude-sonnet-4-6` | +| `claude-opus-4-20250514` | 200K | 32K | $15 | $75 | `claude-opus-4-7` | ## Session Continuity diff --git a/pyproject.toml b/pyproject.toml index d311d14..618d103 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "claude-code-openai-wrapper" -version = "2.6.0" +version = "2.7.0" description = "OpenAI API-compatible wrapper for Claude Code" authors = ["Richard Atkinson "] readme = "README.md" diff --git a/src/__init__.py b/src/__init__.py index a27e737..08a25f6 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ """Claude Code OpenAI Wrapper - A FastAPI-based OpenAI-compatible API for Claude Code.""" -__version__ = "2.6.0" +__version__ = "2.7.0" diff --git a/src/constants.py b/src/constants.py index 9eb0d2c..e4ddae8 100644 --- a/src/constants.py +++ b/src/constants.py @@ -69,14 +69,24 @@ async def chat_endpoint(): ... _DEFAULT_MODEL_META = {"context_window": 200_000, "default_max_output": 32_000, "max_output_limit": 64_000} _MODEL_OVERRIDES = { - "claude-opus-4-6": {"default_max_output": 64_000, "max_output_limit": 128_000}, - "claude-sonnet-4-6": {"max_output_limit": 128_000}, - "claude-3-5-sonnet-20241022": {"default_max_output": 8_192, "max_output_limit": 8_192}, - "claude-3-5-haiku-20241022": {"default_max_output": 8_192, "max_output_limit": 8_192}, + "claude-opus-4-7": { + "context_window": 1_000_000, + "default_max_output": 64_000, + "max_output_limit": 128_000, + }, + "claude-opus-4-6": { + "context_window": 1_000_000, + "default_max_output": 64_000, + "max_output_limit": 128_000, + }, + "claude-sonnet-4-6": {"context_window": 1_000_000}, + "claude-opus-4-1-20250805": {"default_max_output": 32_000, "max_output_limit": 32_000}, + "claude-opus-4-20250514": {"default_max_output": 32_000, "max_output_limit": 32_000}, } # All supported model IDs (order: newest first) _ALL_MODEL_IDS = [ + "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-opus-4-5-20251101", @@ -85,9 +95,6 @@ async def chat_endpoint(): ... "claude-opus-4-1-20250805", "claude-sonnet-4-20250514", "claude-opus-4-20250514", - "claude-3-7-sonnet-20250219", - "claude-3-5-sonnet-20241022", - "claude-3-5-haiku-20241022", ] MODEL_METADATA = { @@ -107,20 +114,17 @@ async def chat_endpoint(): ... _PRICING_OPUS = {"input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25} _PRICING_OPUS_LEGACY = {"input": 15.0, "output": 75.0, "cache_read": 1.50, "cache_write": 18.75} _PRICING_HAIKU_45 = {"input": 1.0, "output": 5.0, "cache_read": 0.10, "cache_write": 1.25} -_PRICING_HAIKU_35 = {"input": 0.80, "output": 4.0, "cache_read": 0.08, "cache_write": 1.00} MODEL_PRICING = { - "claude-sonnet-4-6": _PRICING_SONNET, - "claude-sonnet-4-5-20250929": _PRICING_SONNET, - "claude-sonnet-4-20250514": _PRICING_SONNET, - "claude-3-7-sonnet-20250219": _PRICING_SONNET, - "claude-3-5-sonnet-20241022": _PRICING_SONNET, + "claude-opus-4-7": _PRICING_OPUS, "claude-opus-4-6": _PRICING_OPUS, "claude-opus-4-5-20251101": _PRICING_OPUS, "claude-opus-4-1-20250805": _PRICING_OPUS_LEGACY, "claude-opus-4-20250514": _PRICING_OPUS_LEGACY, + "claude-sonnet-4-6": _PRICING_SONNET, + "claude-sonnet-4-5-20250929": _PRICING_SONNET, + "claude-sonnet-4-20250514": _PRICING_SONNET, "claude-haiku-4-5-20251001": _PRICING_HAIKU_45, - "claude-3-5-haiku-20241022": _PRICING_HAIKU_35, } # Web search cost (per request, all models) @@ -129,6 +133,7 @@ async def chat_endpoint(): ... # Fallback model mapping: when an Opus model is overloaded, fall back to Sonnet # Sourced from Claude Code's FallbackTriggeredError pattern MODEL_FALLBACK_MAP = { + "claude-opus-4-7": "claude-sonnet-4-6", "claude-opus-4-6": "claude-sonnet-4-6", "claude-opus-4-5-20251101": "claude-sonnet-4-5-20250929", "claude-opus-4-1-20250805": "claude-sonnet-4-20250514", diff --git a/src/main.py b/src/main.py index 9248cdb..1883b87 100644 --- a/src/main.py +++ b/src/main.py @@ -1609,7 +1609,7 @@ async def root(): const quickstartCode = `curl -X POST http://localhost:8000/v1/chat/completions \\\\ -H "Content-Type: application/json" \\\\ - -d '{{"model": "claude-sonnet-4-5-20250929", "messages": [{{"role": "user", "content": "Hello!"}}]}}'`; + -d '{{"model": "claude-sonnet-4-6", "messages": [{{"role": "user", "content": "Hello!"}}]}}'`; async function highlightQuickstart() {{ const theme = isDark() ? darkTheme : lightTheme; @@ -1624,7 +1624,7 @@ async def root(): highlightQuickstart();