diff --git a/.env.example b/.env.example index 0779bd0..ae2dbfb 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,11 @@ # Claude CLI Configuration CLAUDE_CLI_PATH=claude +# Gemini CLI Configuration +# GEMINI_API_KEY=your-gemini-api-key-here +# GOOGLE_API_KEY=your-google-api-key-here +GEMINI_CLI_PATH=gemini + # Authentication Method (optional - explicit selection) # Set this to override auto-detection. Values: cli, api_key, bedrock, vertex # If not set, auto-detects based on available env vars (ANTHROPIC_API_KEY, etc.) @@ -13,6 +18,8 @@ CLAUDE_CLI_PATH=claude # Server Configuration PORT=8000 +# Maximum number of concurrent CLI processes allowed (default: 3) +# MAX_CONCURRENT_PROCESSES=3 # Host binding address - use 127.0.0.1 for local-only access, 0.0.0.0 for all interfaces # CLAUDE_WRAPPER_HOST=0.0.0.0 # Maximum request body size in bytes (default: 10MB) @@ -21,6 +28,10 @@ PORT=8000 # Timeout Configuration (milliseconds) MAX_TIMEOUT=600000 +# Prewarming Configuration +# Prompt to use during startup for prewarming the CLI backends (default: Hello) +# PREWARM_PROMPT=Hello + # CORS Configuration CORS_ORIGINS=["*"] diff --git a/.gitignore b/.gitignore index a59cdee..089cd50 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ logs/ # Testing .coverage .pytest_cache/ +.hypothesis/ htmlcov/ # Claude Code @@ -57,4 +58,5 @@ 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 +.worktrees/ diff --git a/PR.md b/PR.md new file mode 100644 index 0000000..45f3edf --- /dev/null +++ b/PR.md @@ -0,0 +1,24 @@ +# Release v2.3.0: Concurrency improvements, SDK options wiring, and critical bug fixes + +This PR introduces version 2.3.0, focusing on significant reliability improvements, full support for concurrent SDK calls, wiring of new Claude API options, and resolutions for several critical proxy bugs. + +## Features & Enhancements +* **SDK Options Wiring:** Full support for `reasoning_effort`, `response_format`, `thinking`, `max_budget_usd`, and `user` fields passed directly to the Claude SDK. +* **Concurrency:** Removed `os.environ` mutex (`_env_lock`) by passing auth via `options.env`, allowing fully concurrent SDK calls. `SessionManager` has been refactored to use `asyncio.Lock` with all session methods converted to async. +* **Token & Reason Mapping:** Extracts real token counts directly from the SDK's `ResultMessage` and properly maps `stop_reason` to `finish_reason` (e.g., `max_tokens` β†’ `length`). +* **Tool Handling:** Changed `AnthropicMessagesRequest.enable_tools` default to `False` so simple message requests do not trigger unintended 10-turn loops. + +## Bug Fixes +* **Session Continuity:** Fixed session continuation by correcting `continue_session` to `continue_conversation` and replaced list appending with replacement to prevent exponential duplication. +* **Timeouts & Hangs:** Wrapped async `query()` iterations with `asyncio.timeout` to prevent indefinite hangs when the SDK subprocess stalls. +* **Proxy Reliability:** + * Removed `filter_content()` from user input which was silently stripping XML-like tags. + * Secured `/v1/auth/status` endpoint with the `verify_api_key()` auth guard. + * Marked the Bash tool as `is_safe=False`. + * Replaced bare `except:` clauses with `except Exception:`. + +## Maintenance & Chores +* Updated `poetry.lock` and the test suite for compatibility with `pydantic 2.13` and `poetry 2.3`. +* Replaced deprecated `datetime.utcnow()` with `datetime.now(timezone.utc)`. +* Ignored `.worktrees` directories in `.gitignore`. +* Added diagnostic print statements for `/v1/messages` and improved the `test_message.py` script. diff --git a/PR_GEMINI.md b/PR_GEMINI.md new file mode 100644 index 0000000..8b9142e --- /dev/null +++ b/PR_GEMINI.md @@ -0,0 +1,27 @@ +# Gemini CLI Proxy Support and Interactive Chat Client + +This PR introduces support for the Gemini CLI as an alternative backend, allowing users to use Gemini models (like Gemini 3 and 2.5) through the OpenAI-compatible proxy. It also includes a new interactive chat client with Markdown rendering. + +## New Features +* **Gemini CLI Proxy:** + * New `GeminiCodeCLI` wrapper for the `@google/gemini-cli` tool. + * Real-time NDJSON stream parsing for low-latency responses. + * Full session continuity support using the CLI's `--resume` flag. + * Integrated model routing: models starting with `gemini-` or using aliases like `pro`, `flash`, `auto` are automatically routed to Gemini. +* **Interactive Chat Client:** + * Added `examples/interactive_chat.py` which manages the background server, provides a rich TUI with `rich` for Markdown rendering, and supports live streaming. +* **Unified Model Listing:** + * Updated `/v1/models` to return both Claude and Gemini models with correct metadata. + +## Enhancements +* **Authentication:** Added support for `GEMINI_API_KEY` and `GOOGLE_API_KEY` in the `ClaudeCodeAuthManager`. +* **Constants:** Defined the latest Gemini model IDs and aliases. +* **Configuration:** Updated `.env.example` with Gemini-specific settings. + +## Bug Fixes & Refactoring +* **Unified Interface:** Refactored `main.py` endpoints to use a common `get_cli_for_model` helper, making it easier to add more backends in the future. +* **Metadata Extraction:** Improved metadata and usage parsing to handle both Anthropic and Gemini formats consistently. + +## Testing +* Added `tests/test_gemini_cli_unit.py` with 100% coverage for the new wrapper. +* Verified both streaming and non-streaming responses for both backends. diff --git a/README.md b/README.md index 2a88602..116e526 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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, allowing you to use Claude Code with any OpenAI client library. **Now powered by the official Claude Agent SDK v0.2.134+** with enhanced authentication and features. ## Version @@ -9,11 +9,16 @@ An OpenAI API-compatible wrapper for Claude Code, allowing you to use Claude Cod - **Dynamic default Sonnet:** `DEFAULT_MODEL` resolves to the latest Sonnet at startup when `ANTHROPIC_API_KEY` is configured; falls back to `claude-sonnet-4-6` otherwise - **Operator overrides:** New `CLAUDE_MODELS_OVERRIDE`, `FAST_MODEL`, and `MODEL_LIST_*` env vars - **Updated catalog:** Claude 4.6 family added to the static fallback list +- **Bug fixes:** `continue_conversation` SDK field corrected; `max_thinking_tokens` now wired through; real token counts from SDK; `finish_reason` mapped from actual `stop_reason` +- **Concurrent requests:** Auth env vars passed via `options.env` β€” no more serialising lock +- **New parameters:** `reasoning_effort`, `response_format`, `max_budget_usd`, `thinking` added to request models +- **Async session manager:** `threading.Lock` replaced with `asyncio.Lock` for proper async safety +- **SDK options refactor:** `run_completion` simplified to accept a `claude_options` dict, enabling generic passthrough of any SDK field -**Upgrading from v1.x?** +**Upgrading from v2.2.0:** 1. Pull latest code: `git pull origin main` 2. Update dependencies: `poetry install` -3. Restart server - that's it! +3. Restart server β€” no breaking changes to the OpenAI/Anthropic API surface **Migration Resources:** - [MIGRATION_STATUS.md](./MIGRATION_STATUS.md) - Detailed v2.0.0 migration status @@ -22,7 +27,7 @@ An OpenAI API-compatible wrapper for Claude Code, allowing you to use Claude Cod ## Status πŸŽ‰ **Production Ready** - All core features working and tested: -- βœ… Chat completions endpoint with **official Claude Agent SDK v0.1.18** +- βœ… Chat completions endpoint with **official Claude Agent SDK v0.2.134+** - βœ… **Anthropic Messages API** (`/v1/messages`) for native compatibility - βœ… Streaming and non-streaming responses - βœ… Full OpenAI SDK compatibility @@ -32,7 +37,9 @@ An OpenAI API-compatible wrapper for Claude Code, allowing you to use Claude Cod - βœ… 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 +- βœ… **Real token counts** from SDK metadata (no more estimates) +- βœ… **Accurate `finish_reason`** mapped from SDK `stop_reason` +- βœ… **Fully concurrent requests** β€” no serialising lock for auth env vars - βœ… **Session continuity** with conversation history across requests - βœ… **Session management endpoints** for full session control - βœ… Health, auth status, and models endpoints @@ -48,11 +55,13 @@ An OpenAI API-compatible wrapper for Claude Code, allowing you to use Claude Cod - Automatic model validation and selection ### πŸ›  **Claude Agent SDK Integration** -- **Official Claude Agent SDK** integration (v0.1.18) πŸ†• +- **Official Claude Agent SDK** integration (v0.2.134+) πŸ†• - **Real-time cost tracking** - actual costs from SDK metadata -- **Accurate token counting** - input/output tokens from SDK +- **Real token counting** - input/output tokens directly from SDK (no estimation) +- **Accurate finish_reason** - mapped from SDK `stop_reason` (`end_turn` β†’ `stop`, `max_tokens` β†’ `length`) - **Session management** - proper session IDs and continuity - **Enhanced error handling** with detailed authentication diagnostics +- **Fully concurrent** - auth env vars passed via SDK options, no serialising mutex - **Modern SDK features** - Latest capabilities and improvements ### πŸ” **Multi-Provider Authentication** @@ -66,6 +75,10 @@ An OpenAI API-compatible wrapper for Claude Code, allowing you to use Claude Cod - **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 +- **`reasoning_effort`** - Map OpenAI `reasoning_effort: "low"|"medium"|"high"` to SDK `effort` +- **`response_format`** - Pass `{"type": "json_object"}` or JSON Schema for structured outputs +- **`thinking`** - Explicit thinking config `{"type": "enabled", "budget_tokens": N}` (overrides `max_tokens` mapping) +- **`max_budget_usd`** - Per-request cost cap in USD - **Development mode** with auto-reload (`uvicorn --reload`) - **Interactive API key protection** - Optional security with auto-generated tokens - **Comprehensive logging** and debugging capabilities @@ -83,7 +96,7 @@ Get started in under 2 minutes: ```bash # 1. Clone and setup the wrapper -git clone https://github.com/RichardAtCT/claude-code-openai-wrapper +git clone https://github.com/gustavokch/claude-code-openai-wrapper cd claude-code-openai-wrapper poetry install # Installs SDK with bundled Claude Code CLI @@ -121,13 +134,13 @@ poetry run python test_endpoints.py ``` - **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! +> **Note:** The Claude Code CLI is bundled with the SDK (v0.2.134+). No separate Node.js or npm installation required! ## Installation 1. Clone the repository: ```bash - git clone https://github.com/RichardAtCT/claude-code-openai-wrapper + git clone https://github.com/gustavokch/claude-code-openai-wrapper cd claude-code-openai-wrapper ``` @@ -473,6 +486,71 @@ for chunk in stream: print(chunk.choices[0].delta.content, end="") ``` +## Advanced Parameters + +These extra fields extend the standard OpenAI request body and are passed through to the Claude Agent SDK. + +### `reasoning_effort` + +Controls the depth of Claude's thinking. Maps to the SDK `effort` field. + +```python +response = client.chat.completions.create( + model="claude-sonnet-4-5-20250929", + messages=[{"role": "user", "content": "Solve this math problem..."}], + extra_body={"reasoning_effort": "high"} # "low" | "medium" | "high" +) +``` + +### `response_format` + +Request structured output. Passed through as SDK `output_format`. + +```python +response = client.chat.completions.create( + model="claude-sonnet-4-5-20250929", + messages=[{"role": "user", "content": "Return JSON with name and age fields."}], + extra_body={"response_format": {"type": "json_object"}} +) +``` + +### `thinking` + +Explicit thinking configuration β€” takes precedence over the `max_tokens β†’ max_thinking_tokens` mapping. + +```python +response = client.chat.completions.create( + model="claude-sonnet-4-6", + messages=[{"role": "user", "content": "Hard reasoning task"}], + extra_body={"thinking": {"type": "enabled", "budget_tokens": 8000}} +) +# Also: {"type": "adaptive"} or {"type": "disabled"} +``` + +### `max_budget_usd` + +Cap per-request cost in USD. The SDK will stop generation when the budget is reached. + +```python +response = client.chat.completions.create( + model="claude-sonnet-4-5-20250929", + messages=[{"role": "user", "content": "Long task..."}], + extra_body={"max_budget_usd": 0.05} # stop at $0.05 +) +``` + +### `max_tokens` / `max_completion_tokens` + +Maps to the SDK's `max_thinking_tokens` (best-effort). For precise control use `thinking` above. + +```python +response = client.chat.completions.create( + model="claude-sonnet-4-5-20250929", + messages=[{"role": "user", "content": "Brief answer please"}], + max_tokens=512 +) +``` + ## Supported Models The wrapper exposes Claude's full model catalog. When `ANTHROPIC_API_KEY` is set, `/v1/models` returns Anthropic's live list (cached for 1 hour) and the wrapper picks the latest Sonnet as `DEFAULT_MODEL` at startup. When the key is absent β€” for example, when running with Bedrock, Vertex, or Claude CLI subscription auth β€” the static list below is served and `claude-sonnet-4-6` is used as the fallback default. Operators who want a curated list regardless of auth can set `CLAUDE_MODELS_OVERRIDE`. @@ -493,6 +571,30 @@ The wrapper exposes Claude's full model catalog. When `ANTHROPIC_API_KEY` is set **Note:** Claude 3.x models are not supported by the Claude Agent SDK. The model parameter is passed to Claude Code via the SDK's model selection. +## Using non-Claude models via passthrough (e.g. GLM-5.2) + +The wrapper can serve any model that your Claude Code installation can reach, +including non-Anthropic models such as **GLM-5.2**. The wrapper does not call +the model provider directly β€” it forwards the model name to Claude Code, which +must already be configured to reach the provider. + +**Prerequisite β€” point Claude Code at the provider.** Set these on the Claude +Code process (your environment, not wrapper code): +- `ANTHROPIC_BASE_URL` β€” your proxy that speaks the Anthropic API format and + forwards to the provider (e.g. a GLM endpoint). +- `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_API_KEY` β€” the credential your proxy + requires, if any. + +**Use it through the wrapper.** Send the model name in the request: +```bash +curl http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model":"glm-5.2","messages":[{"role":"user","content":"hello"}]}' +``` + +To make GLM the default, set `DEFAULT_MODEL=glm-5.2` for the wrapper. `glm-5.2` +is advertised in `GET /v1/models`. + ## Session Continuity πŸ†• 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. @@ -607,26 +709,32 @@ See `examples/session_continuity.py` for comprehensive Python examples and `exam ### 🚫 **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` +- **OpenAI parameters** not mapped: `temperature`, `top_p`, `logit_bias`, `presence_penalty`, `frequency_penalty` (ignored with a warning) - **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 +### πŸ›£ **Planned Enhancements** +- [ ] **Token-level streaming** - `include_partial_messages` for finer chunks - [ ] **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 +- [ ] **Temperature/top_p** - native SDK mapping when available + +### βœ… **Recent Improvements (v2.3.0)** +- **Bug fixes:** `continue_conversation` field corrected; `max_thinking_tokens` now wired through to SDK +- **Real token counts**: response `usage` comes from SDK metadata, not character estimation +- **Accurate `finish_reason`**: mapped from SDK `stop_reason` (`max_tokens` β†’ `length`, etc.) +- **Concurrent requests**: auth env vars via `options.env` β€” no serialising mutex +- **New parameters**: `reasoning_effort`, `response_format`, `max_budget_usd`, `thinking` +- **Async session manager**: `threading.Lock` β†’ `asyncio.Lock` for proper async safety + +### βœ… **v2.2.0 Features** +- 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 +- Claude Agent SDK v0.2.134+ 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 @@ -679,13 +787,16 @@ curl http://localhost:8000/v1/auth/status | python -m json.tool ### βš™οΈ **Development Tools** ```bash # Install development dependencies -poetry install --with dev +poetry install # Format code poetry run black . -# Run full tests (when implemented) -poetry run pytest tests/ +# Run unit tests (no server required) +PYTHONPATH=$(pwd) poetry run pytest tests/test_claude_cli_unit.py tests/test_session_manager_unit.py tests/test_models_unit.py -v + +# Run full test suite (unit + integration, server must be running for integration tests) +PYTHONPATH=$(pwd) poetry run pytest tests/ -v ``` ### βœ… **Expected Results** diff --git a/docs/UPGRADE_PLAN.md b/docs/UPGRADE_PLAN.md index a2348ea..77628d3 100644 --- a/docs/UPGRADE_PLAN.md +++ b/docs/UPGRADE_PLAN.md @@ -1,8 +1,9 @@ # Claude Code OpenAI Wrapper - Upgrade Plan +**Status:** SDK upgraded to claude-agent-sdk >=0.2.134 (resolved to 0.2.135); CLI remains 2.1.226 **Date:** 2025-11-02 **Current Version:** claude-code-sdk 0.0.14 -**Target Version:** claude-agent-sdk 0.1.6 +**Target Version:** claude-agent-sdk >=0.2.134 ## Executive Summary @@ -28,7 +29,7 @@ This document outlines a comprehensive plan to upgrade the Claude Code OpenAI Wr ### 1.2 Target State -**Target SDK:** `claude-agent-sdk` version 0.1.6 +**Target SDK:** `claude-agent-sdk` >=0.2.134 (resolved to 0.2.135) - **Released:** October 31, 2025 - **Python Requirements:** Python >=3.10 - **Additional Requirements:** @@ -56,7 +57,7 @@ pip install claude-agent-sdk claude-code-sdk = "^0.0.14" # After: -claude-agent-sdk = "^0.1.6" +claude-agent-sdk = ">=0.2.134,<0.3" ``` #### 1.3.2 Import Statement Changes @@ -199,7 +200,7 @@ async with ClaudeSDKClient(options=options) as client: ### 1.4 Migration Implementation Plan #### Phase 1: Dependency Update -- [ ] Update `pyproject.toml` with `claude-agent-sdk = "^0.1.6"` +- [ ] Update `pyproject.toml` with `claude-agent-sdk = ">=0.2.134,<0.3"` - [ ] Remove `claude-code-sdk` from dependencies - [ ] Run `poetry lock` and `poetry install` - [ ] Verify installation: `poetry show claude-agent-sdk` @@ -760,7 +761,7 @@ options = ClaudeAgentOptions( claude-code-sdk = "^0.0.14" # After -claude-agent-sdk = "^0.1.6" +claude-agent-sdk = ">=0.2.134,<0.3" ``` ### Key Commands diff --git a/docs/superpowers/plans/2026-08-10-sync-sdk-upgrade-glm.md b/docs/superpowers/plans/2026-08-10-sync-sdk-upgrade-glm.md new file mode 100644 index 0000000..4736b92 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-sync-sdk-upgrade-glm.md @@ -0,0 +1,638 @@ +# Sync + SDK 0.2.134 Upgrade + GLM-5.2 Passthrough Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Merge the 2 missing upstream commits from `RichardAtCT`, upgrade `claude-agent-sdk` 0.1.18 β†’ 0.2.134 with a hardened typed message parser, and enable `glm-5.2` as a passthrough model β€” all on branch `feat/sync-sdk-upgrade-glm`. + +**Architecture:** Three sequential layers on one feature branch. (1) `git merge upstream/main` to bring in upstream's dynamic model-list code while preserving the fork's Gemini additions. (2) Bump the SDK dependency and replace the fragile `dir()`-walk message converter in `src/claude_cli.py` with typed `isinstance` dispatch against SDK 0.2.x message classes. (3) Advertise `glm-5.2` in `/v1/models` and confirm it routes through the existing Claude passthrough path. + +**Tech Stack:** Python 3.10+, FastAPI, Poetry, `claude-agent-sdk` 0.2.134, pytest/pytest-asyncio, Claude Code CLI 2.1.226. + +## Global Constraints + +- **Do not** change the Claude Code CLI version. It stays at `2.1.226` (installed). No `npm install -g @anthropic-ai/claude-code` step. +- **Do not** build a direct GLM backend. GLM is served *through* Claude Code via the operator's `ANTHROPIC_BASE_URL` proxy. The wrapper only forwards the model name. +- **Do not** rebase or force-push. Sync via `git merge`. `main` is untouched until the final PR. +- SDK version pin after Task 2: `claude-agent-sdk = ">=0.2.134,<0.3"` in `pyproject.toml`. +- All work is on branch `feat/sync-sdk-upgrade-glm` (already created, currently holds only the spec commit). +- Conventional commit prefixes (`feat:`, `fix:`, `chore:`, `docs:`), matching the repo's history. +- Every task ends with `pytest` green before committing. + +**Spec:** `docs/superpowers/specs/2026-08-10-sync-sdk-upgrade-glm-design.md` + +--- + +## File Structure + +| File | Responsibility | Touched by | +|---|---|---| +| `pyproject.toml` | Dependency pin for `claude-agent-sdk` | Task 2 | +| `src/claude_cli.py` | SDK query + message normalization; `_message_to_dict` parser | Task 3 | +| `src/constants.py` | Model lists (dynamic + static), `GLM_MODELS`, `PASSTHROUGH_MODELS` | Task 1 (merge), Task 4 | +| `src/main.py` | `/v1/models` endpoint + `_append_passthrough` + `get_cli_for_model` | Task 1 (merge), Task 4 | +| `src/models.py` | `to_claude_options()` model passthrough | Task 1 (merge, preserve) | +| `tests/test_message_parser_unit.py` | NEW β€” typed parser unit tests | Task 3 | +| `tests/test_glm_passthrough_unit.py` | NEW β€” GLM advertise + routing tests | Task 4 | +| `README.md`, `docs/UPGRADE_PLAN.md` | Version refs + GLM docs | Task 5 | + +--- + +## Task 1: Merge `upstream/main` into the feature branch + +**Files:** +- Modify (via merge resolution): `src/constants.py`, `src/main.py`, `src/models.py`, `README.md`, `src/__init__.py`, `pyproject.toml`, `.env.example`, `tests/test_sdk_migration.py` +- Add (from upstream): `tests/test_dynamic_models.py` + +**Interfaces:** +- Consumes: current branch `feat/sync-sdk-upgrade-glm` (spec commit only). +- Produces: a merged tree where upstream's dynamic model-list code is present AND the fork's Gemini routing (`get_cli_for_model`), Anthropic Messages endpoint, and `to_claude_options()` model passthrough still work. + +- [ ] **Step 1: Add the upstream remote and fetch** + +```bash +git remote add upstream https://github.com/RichardAtCT/claude-code-openai-wrapper.git +git fetch upstream +git remote -v # confirm: origin -> gustavokch, upstream -> RichardAtCT +``` +Expected: `upstream` appears; fetch prints the 2 new commits (`ba9b039e`, `74951748`). + +- [ ] **Step 2: Merge upstream/main** + +```bash +git merge upstream/main --no-edit +``` +Expected: merge reports conflicts in `src/constants.py` (and possibly `src/main.py`, `README.md`, `pyproject.toml`). The merge stops for manual resolution. + +- [ ] **Step 3: Resolve `src/constants.py`** + +Resolution policy β€” **take upstream's dynamic structure, re-add the fork's static additions**: +- Keep upstream's `DEFAULT_CLAUDE_MODELS`, the `CLAUDE_MODELS_OVERRIDE`/`CLAUDE_MODELS` block, `DEFAULT_MODEL_ENV`/`DEFAULT_MODEL_FALLBACK`/`DEFAULT_MODEL`/`RESOLVED_DEFAULT_MODEL`, `FAST_MODEL` env override, and the `ANTHROPIC_MODELS_URL`/`ANTHROPIC_VERSION`/`MODEL_LIST_*` config. +- **Re-add** the fork's `GEMINI_MODELS` list (upstream does not have it): +```python +# Gemini Models +# Models supported by Gemini CLI +GEMINI_MODELS = [ + "gemini-3-pro-preview", + "gemini-3-flash-preview", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "pro", # Alias for gemini-3-pro-preview + "flash", # Alias for gemini-2.5-flash + "flash-lite", # Alias for gemini-2.5-flash-lite + "auto", # Alias for gemini-3-pro-preview (recommended) +] +``` + Place it after the `CLAUDE_MODELS` block. (Do **not** add `GLM_MODELS` here β€” that is Task 4.) +- Verify the file imports cleanly: `python -c "import src.constants as c; print(c.CLAUDE_MODELS[:2], c.GEMINI_MODELS[:2])"`. + +- [ ] **Step 4: Resolve `src/main.py`** + +Resolution policy β€” **accept upstream's dynamic model-listing code; keep the fork's Gemini + Anthropic-Messages code**: +- Keep upstream's `_model_list_cache`, `_fetch_anthropic_models`, `get_available_models`, `_pick_latest_sonnet`, `_resolve_default_model_at_startup`, `_fallback_model_payload`, and the `@app.get("/v1/models")` β†’ `list_models` endpoint. +- Keep the fork's `get_cli_for_model` (Gemini routing) and both API surface paths (OpenAI `/v1/chat/completions` + Anthropic `/v1/messages`). +- After resolving, confirm `get_cli_for_model` is intact: +```bash +python -c "from src.main import get_cli_for_model; print(get_cli_for_model('glm-5.2') is not None)" +``` +Expected: `True`. + +- [ ] **Step 5: Resolve `src/models.py`** + +Resolution policy β€” **accept upstream's dynamic-list changes; preserve the fork's `to_claude_options()` model passthrough**. Confirm: +```bash +python -c "from src.models import ChatCompletionRequest; r=ChatCompletionRequest(messages=[{'role':'user','content':'hi'}], model='glm-5.2'); print(r.to_claude_options().get('model'))" +``` +Expected: `glm-5.2`. + +- [ ] **Step 6: Resolve remaining trivial conflicts** + +- `pyproject.toml` β€” both sides are `version = "2.3.0"` and `claude-agent-sdk = "^0.1.18"`; keep either side (identical). +- `README.md`, `src/__init__.py`, `.env.example`, `tests/test_sdk_migration.py` β€” take upstream's version, re-apply any fork-specific wording (e.g. the fork's Gemini references in README). + +- [ ] **Step 7: Stage and continue the merge** + +```bash +git add -A +git status # confirm "All conflicts fixed" +git merge --continue +``` +Expected: merge commit created. + +- [ ] **Step 8: Run the full test suite (gate)** + +```bash +poetry install --sync # ensure deps match merged pyproject +poetry run pytest -q +``` +Expected: all tests pass, including upstream's new `tests/test_dynamic_models.py`. If a fork test fails because of the merge, fix the merge resolution (do not delete the test). + +- [ ] **Step 9: Commit verification note (merge is already committed)** + +```bash +git log --oneline -3 +``` +Expected: top commit is the merge. No extra commit needed; proceed to Task 2. + +--- + +## Task 2: Bump `claude-agent-sdk` to 0.2.134 + +**Files:** +- Modify: `pyproject.toml` + +**Interfaces:** +- Consumes: merged tree from Task 1. +- Produces: `claude-agent-sdk` 0.2.134 installed and importable (`query`, `ClaudeAgentOptions`, `AssistantMessage`, `ResultMessage`, `SystemMessage`, `TextBlock`). + +- [ ] **Step 1: Update the dependency pin** + +In `pyproject.toml`, change: +```toml +claude-agent-sdk = "^0.1.18" +``` +to: +```toml +claude-agent-sdk = ">=0.2.134,<0.3" +``` + +- [ ] **Step 2: Lock and install** + +```bash +poetry lock +poetry install --sync +``` +Expected: lock updates; install completes. + +- [ ] **Step 3: Verify the installed version and imports** + +```bash +poetry show claude-agent-sdk | head -3 +poetry run python -c "from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, ResultMessage, SystemMessage, TextBlock; print('imports ok')" +``` +Expected: version reports `0.2.134`; imports print `imports ok`. + +- [ ] **Step 4: Run the suite, record any breakage** + +```bash +poetry run pytest -q +``` +Expected: most tests pass. If SDK message-shape drift breaks a test, note it β€” Task 3 fixes the parser. Do not patch ad-hoc here; capture the failure output for Task 3. + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml poetry.lock +git commit -m "chore: bump claude-agent-sdk 0.1.18 -> 0.2.134 + +Co-Authored-By: Claude " +``` + +--- + +## Task 3: Harden the SDK message parser with typed `isinstance` dispatch + +**Files:** +- Modify: `src/claude_cli.py` (imports line 10; the message loop at lines 152-176) +- Test: `tests/test_message_parser_unit.py` (NEW) + +**Interfaces:** +- Consumes: SDK 0.2.134 classes `AssistantMessage`, `ResultMessage`, `SystemMessage`, `TextBlock`. +- Produces: module-level `_message_to_dict(message) -> Dict[str, Any]` that normalizes any SDK message into the dict shape consumed by `parse_claude_message()` and `extract_metadata()`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_message_parser_unit.py`: +```python +"""Unit tests for the typed SDK message parser (_message_to_dict).""" + +from claude_agent_sdk import AssistantMessage, ResultMessage, SystemMessage, TextBlock + +from src.claude_cli import _message_to_dict + + +def test_assistant_message_keeps_textblock_content(): + msg = AssistantMessage(content=[TextBlock(text="hello world")], model="glm-5.2") + d = _message_to_dict(msg) + assert d["type"] == "assistant" + assert isinstance(d["content"], list) + assert d["content"][0].text == "hello world" + + +def test_result_message_fields_preserved(): + msg = ResultMessage( + subtype="success", + duration_ms=100, + duration_api_ms=80, + is_error=False, + num_turns=2, + session_id="sess-1", + result="done", + total_cost_usd=0.01, + usage={"input_tokens": 10, "output_tokens": 5}, + stop_reason="end_turn", + ) + d = _message_to_dict(msg) + assert d["type"] == "result" + assert d["subtype"] == "success" + assert d["result"] == "done" + assert d["session_id"] == "sess-1" + assert d["total_cost_usd"] == 0.01 + assert d["num_turns"] == 2 + assert d["is_error"] is False + assert d["stop_reason"] == "end_turn" + + +def test_system_message_init_data_preserved(): + msg = SystemMessage( + subtype="init", + data={"session_id": "sess-1", "model": "glm-5.2"}, + ) + d = _message_to_dict(msg) + assert d["type"] == "system" + assert d["subtype"] == "init" + assert d["data"]["session_id"] == "sess-1" + assert d["data"]["model"] == "glm-5.2" + + +def test_dict_passthrough_unchanged(): + original = { + "type": "result", + "subtype": "error_during_execution", + "is_error": True, + "error_message": "boom", + } + assert _message_to_dict(original) is original + + +def test_unknown_object_falls_back_to_attr_copy(): + class Unknown: + type = "weird" + foo = "bar" + + d = _message_to_dict(Unknown()) + assert d.get("foo") == "bar" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +poetry run pytest tests/test_message_parser_unit.py -q +``` +Expected: FAIL β€” `ImportError: cannot import name '_message_to_dict' from 'src.claude_cli'`. + +- [ ] **Step 3: Add the typed imports** + +In `src/claude_cli.py`, replace line 10: +```python +from claude_agent_sdk import query, ClaudeAgentOptions +``` +with: +```python +from claude_agent_sdk import ( + query, + ClaudeAgentOptions, + AssistantMessage, + ResultMessage, + SystemMessage, +) +``` + +- [ ] **Step 4: Add the `_message_to_dict` helper** + +Add this module-level function immediately after the `logger = logging.getLogger(__name__)` line (after line 12), before `class ClaudeCodeCLI`: +```python +def _message_to_dict(message: Any) -> Dict[str, Any]: + """Normalize an SDK message into the dict shape the downstream parser expects. + + Uses typed isinstance checks against the SDK 0.2.x message classes so a + field rename does not silently break extraction. Dicts pass through + unchanged (e.g. injected error results). Unknown message types fall back to + copying public, non-callable attributes. + """ + if isinstance(message, dict): + return message + + if isinstance(message, ResultMessage): + return { + "type": "result", + "subtype": message.subtype, + "result": message.result, + "total_cost_usd": message.total_cost_usd, + "duration_ms": message.duration_ms, + "num_turns": message.num_turns, + "session_id": message.session_id, + "usage": message.usage, + "stop_reason": message.stop_reason, + "is_error": message.is_error, + } + + if isinstance(message, SystemMessage): + return { + "type": "system", + "subtype": message.subtype, + "data": message.data, + } + + if isinstance(message, AssistantMessage): + return { + "type": "assistant", + "content": list(message.content or []), + } + + # Generic fallback for any other SDK message type (UserMessage, etc.). + message_dict: Dict[str, Any] = {} + for attr_name in dir(message): + if attr_name.startswith("_"): + continue + try: + value = getattr(message, attr_name) + except Exception: + continue + if not callable(value): + message_dict[attr_name] = value + return message_dict or {"type": "unknown"} +``` + +- [ ] **Step 5: Replace the inline `dir()`-walk with the helper** + +In `src/claude_cli.py`, inside `_run_completion_inner`, replace the message-conversion block (the `async for message in query(...)` body that currently does the `hasattr(message, "__dict__")` + `dir()` walk) with: +```python + async with asyncio.timeout(self.timeout): + 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}") + yield _message_to_dict(message) +``` +Leave the surrounding `try/except` and the error-yield dict in the `except` block unchanged (it already yields a plain dict, which `_message_to_dict` passes through if ever routed through it). + +- [ ] **Step 6: Run the parser tests β€” verify pass** + +```bash +poetry run pytest tests/test_message_parser_unit.py -q +``` +Expected: 5 passed. + +- [ ] **Step 7: Run the full suite β€” verify no regression** + +```bash +poetry run pytest -q +``` +Expected: all green. Any test that broke under Task 2's SDK bump should now pass (the typed parser handles the 0.2.x message shapes). + +- [ ] **Step 8: Commit** + +```bash +git add src/claude_cli.py tests/test_message_parser_unit.py +git commit -m "refactor: harden SDK message parser with typed isinstance dispatch + +Replace the dir()-walk object->dict conversion with explicit checks against +AssistantMessage/ResultMessage/SystemMessage so SDK 0.2.x field shapes are +handled reliably. Unknown types fall back to attribute copy. + +Co-Authored-By: Claude " +``` + +--- + +## Task 4: GLM-5.2 passthrough (advertise + verify routing) + +**Files:** +- Modify: `src/constants.py` (add `GLM_MODELS`, `PASSTHROUGH_MODELS`) +- Modify: `src/main.py` (add `_append_passthrough`, apply at `/v1/models`) +- Test: `tests/test_glm_passthrough_unit.py` (NEW) + +**Interfaces:** +- Consumes: merged `constants.py` (has `CLAUDE_MODELS`, `GEMINI_MODELS`) and `main.py` (`get_cli_for_model`, `list_models`). +- Produces: `glm-5.2` advertised in `/v1/models`; `PASSTHROUGH_MODELS` constant; `_append_passthrough(models)` helper in `main.py`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_glm_passthrough_unit.py`: +```python +"""Unit tests for GLM-5.2 passthrough: advertisement + routing.""" + +from src.constants import GLM_MODELS, PASSTHROUGH_MODELS +from src.main import _append_passthrough, get_cli_for_model, claude_cli, gemini_cli + + +def test_glm_model_listed(): + assert "glm-5.2" in GLM_MODELS + assert "glm-5.2" in PASSTHROUGH_MODELS + + +def test_append_passthrough_adds_glm(): + result = _append_passthrough([{"id": "claude-sonnet-4-6", "object": "model"}]) + ids = [m["id"] for m in result] + assert "glm-5.2" in ids + assert "claude-sonnet-4-6" in ids + + +def test_append_passthrough_dedupes(): + models = [{"id": "glm-5.2", "object": "model"}] + result = _append_passthrough(models) + assert sum(1 for m in result if m["id"] == "glm-5.2") == 1 + + +def test_glm_routes_to_claude_cli(): + assert get_cli_for_model("glm-5.2") is claude_cli + assert get_cli_for_model("glm-5.2") is not gemini_cli +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +poetry run pytest tests/test_glm_passthrough_unit.py -q +``` +Expected: FAIL β€” `ImportError: cannot import name 'GLM_MODELS'` (and `_append_passthrough`). + +- [ ] **Step 3: Add `GLM_MODELS` and `PASSTHROUGH_MODELS` to `constants.py`** + +In `src/constants.py`, immediately after the `GEMINI_MODELS` block (added in Task 1), add: +```python +# GLM Models +# Served through Claude Code via a custom ANTHROPIC_BASE_URL proxy. The wrapper +# only forwards the model name; it never calls a GLM endpoint directly. +GLM_MODELS = [ + "glm-5.2", +] + +# Non-Anthropic models advertised in /v1/models in addition to the live list. +# They never appear in Anthropic's live Models API response, so they are +# appended at the /v1/models edge (see _append_passthrough in main.py). +PASSTHROUGH_MODELS = GLM_MODELS + GEMINI_MODELS +``` + +- [ ] **Step 4: Add `_append_passthrough` to `main.py`** + +In `src/main.py`, first update the constants import (around line 61) to include the new names. Add `GLM_MODELS` and `PASSTHROUGH_MODELS` to the existing `from src.constants import (...)` line. + +Then add this helper near `get_available_models` (after that function's definition): +```python +def _append_passthrough(models: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Append non-Anthropic passthrough models (GLM, Gemini) to a model list. + + These models are served through Claude Code or the Gemini proxy and are + never returned by Anthropic's live Models API, so they are merged in here + at the /v1/models edge. Already-present ids are not duplicated. + """ + existing_ids = {m.get("id") for m in models} + augmented = list(models) + for model_id in PASSTHROUGH_MODELS: + if model_id not in existing_ids: + augmented.append( + {"id": model_id, "object": "model", "created": 0, "owned_by": "passthrough"} + ) + return augmented +``` + +- [ ] **Step 5: Apply `_append_passthrough` at the `/v1/models` endpoint** + +In `src/main.py`, find the `list_models` endpoint (the `@app.get("/v1/models")` handler). Change its return from: +```python + return {"object": "list", "data": await get_available_models()} +``` +to: +```python + return {"object": "list", "data": _append_passthrough(await get_available_models())} +``` + +- [ ] **Step 6: Run the GLM tests β€” verify pass** + +```bash +poetry run pytest tests/test_glm_passthrough_unit.py -q +``` +Expected: 4 passed. + +- [ ] **Step 7: Run the full suite β€” verify no regression** + +```bash +poetry run pytest -q +``` +Expected: all green. + +- [ ] **Step 8: Commit** + +```bash +git add src/constants.py src/main.py tests/test_glm_passthrough_unit.py +git commit -m "feat: advertise glm-5.2 as a passthrough model in /v1/models + +GLM-5.2 is served through Claude Code via ANTHROPIC_BASE_URL. Add GLM_MODELS, +append passthrough models (GLM + Gemini) at the /v1/models edge so they are +discoverable even though they never appear in Anthropic's live Models API. + +Co-Authored-By: Claude " +``` + +--- + +## Task 5: Documentation + +**Files:** +- Modify: `README.md` +- Modify: `docs/UPGRADE_PLAN.md` + +**Interfaces:** +- Consumes: final state of Tasks 1–4. +- Produces: accurate version references + a GLM passthrough setup section. + +- [ ] **Step 1: Update SDK version references in README** + +In `README.md`, replace every occurrence of `0.1.18` with `0.2.134` (search: `grep -n "0\.1\.18" README.md`). Common locations: the "powered by the official Claude Agent SDK" line and the installation/prerequisites notes. + +- [ ] **Step 2: Add a GLM passthrough section to README** + +Add this section near the existing model/configuration docs: +```markdown +## Using non-Claude models via passthrough (e.g. GLM-5.2) + +The wrapper can serve any model that your Claude Code installation can reach, +including non-Anthropic models such as **GLM-5.2**. The wrapper does not call +the model provider directly β€” it forwards the model name to Claude Code, which +must already be configured to reach the provider. + +**Prerequisite β€” point Claude Code at the provider.** Set these on the Claude +Code process (your environment, not wrapper code): +- `ANTHROPIC_BASE_URL` β€” your proxy that speaks the Anthropic API format and + forwards to the provider (e.g. a GLM endpoint). +- `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_API_KEY` β€” the credential your proxy + requires, if any. + +**Use it through the wrapper.** Send the model name in the request: +```bash +curl http://localhost:8000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model":"glm-5.2","messages":[{"role":"user","content":"hello"}]}' +``` + +To make GLM the default, set `DEFAULT_MODEL=glm-5.2` for the wrapper. `glm-5.2` +is advertised in `GET /v1/models`. +``` + +- [ ] **Step 3: Mark the SDK migration complete in UPGRADE_PLAN** + +In `docs/UPGRADE_PLAN.md`, update the header target version from `claude-agent-sdk 0.1.6` / `0.1.18` references to `0.2.134`, and add a one-line status under the title: +```markdown +**Status:** SDK upgraded to claude-agent-sdk 0.2.134 (2026-08-10). CLI remains 2.1.226. +``` + +- [ ] **Step 4: Verify docs build/links are not broken** + +```bash +grep -rn "0\.1\.18" README.md docs/UPGRADE_PLAN.md +``` +Expected: no matches (all updated to `0.2.134`). + +- [ ] **Step 5: Commit** + +```bash +git add README.md docs/UPGRADE_PLAN.md +git commit -m "docs: update SDK version to 0.2.134 and document GLM-5.2 passthrough + +Co-Authored-By: Claude " +``` + +--- + +## Task 6: Final verification + manual smoke + +**Files:** none (verification only) + +- [ ] **Step 1: Full test suite** + +```bash +poetry run pytest -q +``` +Expected: all green, including `test_dynamic_models.py`, `test_message_parser_unit.py`, `test_glm_passthrough_unit.py`. + +- [ ] **Step 2: Confirm dependency + CLI versions** + +```bash +poetry show claude-agent-sdk | head -3 # 0.2.134 +claude --version # 2.1.226 +``` + +- [ ] **Step 3: Manual smoke β€” streaming + session continuity** + +Start the server and run a streaming chat completion against a Claude model, then a second request reusing the returned `session_id`. Confirm the assistant remembers the first turn. + +- [ ] **Step 4: Manual smoke β€” GLM-5.2 (operator-run)** + +With Claude Code pointed at GLM via `ANTHROPIC_BASE_URL`, send a chat completion with `model: "glm-5.2"` and confirm a response. Then `GET /v1/models` and confirm `glm-5.2` is listed. + +- [ ] **Step 5: Push the branch and open a PR** + +```bash +git push -u origin feat/sync-sdk-upgrade-glm +gh pr create --title "Sync upstream + upgrade SDK to 0.2.134 + GLM-5.2 passthrough" \ + --body "Merges RichardAtCT upstream (dynamic model list), bumps claude-agent-sdk 0.1.18 -> 0.2.134 with a hardened typed message parser, and advertises glm-5.2 as a passthrough model. See docs/superpowers/specs/2026-08-10-sync-sdk-upgrade-glm-design.md." +``` +Expected: PR opens against `main`. + +--- + +## Self-Review (completed during authoring) + +- **Spec coverage:** Part 1 (sync) β†’ Task 1. Part 2 (SDK bump + hardening) β†’ Tasks 2–3. Part 3 (GLM passthrough) β†’ Task 4. Docs β†’ Task 5. Verify β†’ Task 6. All spec sections mapped. +- **Placeholders:** none. Each code step contains the actual code; merge steps contain exact git commands + per-file resolution policy. +- **Type consistency:** `_message_to_dict` is defined in Task 3 Step 4 and tested in Task 3 Step 1 with matching signatures. `_append_passthrough`, `GLM_MODELS`, `PASSTHROUGH_MODELS` are defined and tested consistently in Task 4. `get_cli_for_model` is the existing name from `main.py:439`. diff --git a/docs/superpowers/specs/2026-08-10-sync-sdk-upgrade-glm-design.md b/docs/superpowers/specs/2026-08-10-sync-sdk-upgrade-glm-design.md new file mode 100644 index 0000000..2e73d2a --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-sync-sdk-upgrade-glm-design.md @@ -0,0 +1,207 @@ +# Sync + SDK 0.2.134 Upgrade + GLM-5.2 Passthrough β€” Design + +**Date:** 2026-08-10 +**Author:** brainstorming session +**Status:** Approved (pending implementation plan) +**Branch (planned):** `feat/sync-sdk-upgrade-glm` + +--- + +## 1. Context + +`claude-code-openai-wrapper` (this repo) is a GitHub fork. The fork's `origin` is +`gustavokch/claude-code-openai-wrapper`; the true GitHub parent (upstream) is +`RichardAtCT/claude-code-openai-wrapper`. The local working copy is fully in sync +with `origin` (0 commits ahead / behind). The fork is **20 commits ahead** and +**2 commits behind** upstream. + +The wrapper exposes Claude Code (via the `claude-agent-sdk` Python package) as an +OpenAI-compatible API. It pins `claude-agent-sdk = "^0.1.18"`. The locally +installed Claude Code CLI is `2.1.226`. + +The user's Claude Code installation runs on the **GLM-5.2** model through a custom +endpoint (the active session reports `glm-5.2[1m]`). The wrapper must expose that +model without rejecting it. + +### 1.1 Version reality (verified 2026-08-10) + +| Component | Fork current | Latest available | Note | +|---|---|---|---| +| `claude-agent-sdk` (PyPI) | `0.1.18` | `0.2.134` (released 2026-08-08) | Real upgrade available | +| `@anthropic-ai/claude-code` (npm) | `2.1.226` (installed) | `2.1.226` | `2.1.266` **does not exist** on npm | + +The request to "match Claude Code v2.1.266" targets a version that is not +published. The agreed target is therefore **"current"**: upgrade the SDK to +`0.2.134` and keep the CLI at `2.1.226`. + +--- + +## 2. Goals + +1. **Sync** the fork with upstream `RichardAtCT`, integrating the 2 missing + commits while preserving the 20 local commits. +2. **Upgrade** `claude-agent-sdk` `0.1.18 β†’ 0.2.134`, fixing any message-handling + drift, and harden the SDK message parser against future field changes. +3. **Enable GLM-5.2** as a passthrough model so clients can send + `model: "glm-5.2"` and have it forwarded to a Claude Code backend that is + pointed at GLM. + +## 3. Non-goals + +- Do **not** change the Claude Code CLI version (stay `2.1.226`). +- Do **not** build a direct GLM backend. GLM is served **through** Claude Code. +- Do **not** rewrite the wrapper architecture; touch only version-coupled and + GLM-relevant code. +- Do **not** force-push. History is preserved via a merge. + +--- + +## 4. Locked decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Sync method | **Merge** `upstream/main` (not rebase) | Preserves the 20-commit PR history; no force-push on a published fork | +| Work order | Sync β†’ SDK bump β†’ GLM | Sync brings the dynamic-model-list foundation GLM builds on | +| Version target | SDK `0.2.134`, CLI `2.1.226` | `2.1.266` is unpublished; `2.1.226` is current | +| GLM path | **Passthrough** via Claude Code | User's Claude Code already serves GLM; wrapper only forwards the name | +| SDK parser | **Rewrite** to typed `isinstance` checks | Robust against future SDK message-field changes | + +--- + +## 5. Verified facts (grounding for the plan) + +- **Model flow already passes the model end-to-end:** + - `src/models.py:200-201` β€” `to_claude_options()` sets `options["model"] = self.model`. + - `src/main.py:488,539` β€” streaming path builds options and passes them to `run_completion`. + - `src/claude_cli.py:142-144` β€” generic `setattr` loop sets `model` on `ClaudeAgentOptions` when present. +- **Validation never rejects a model:** `src/parameter_validator.py:23-30` β€” `validate_model()` logs a warning then returns `True` (graceful degradation). +- **Routing already sends GLM to the Claude path:** `src/main.py:439-446` β€” `get_cli_for_model()` returns `gemini_cli` only for `gemini*` / `pro` / `flash` / `flash-lite` / `auto`; everything else (including `glm-5.2`) returns `claude_cli`. +- **SDK 0.2.134 public API (verified against repo README):** `query`, `ClaudeAgentOptions` survive. Typed message classes are first-class: `AssistantMessage`, `UserMessage`, `SystemMessage`, `ResultMessage`, `TextBlock`, `ToolUseBlock`, `ToolResultBlock`. New: `ClaudeAgentOptions(cli_path=...)`, `ClaudeSDKClient`, `HookMatcher`, in-process MCP (`tool`, `create_sdk_mcp_server`). +- **The 2 missing upstream commits:** + - `ba9b039e` "feat: dynamically refresh Anthropic model list (#46)" β€” touches `.env.example`, `README.md`, `src/constants.py`, `src/main.py`, `src/models.py`, adds `tests/test_dynamic_models.py`, touches `tests/test_sdk_migration.py`. + - `74951748` "chore: release v2.3.0" β€” touches `README.md`, `pyproject.toml`, `src/__init__.py`. + +--- + +## 6. Part 1 β€” Sync with `RichardAtCT` + +### 6.1 Steps +1. `git remote add upstream https://github.com/RichardAtCT/claude-code-openai-wrapper.git` +2. `git fetch upstream` +3. From `main`, create `feat/sync-sdk-upgrade-glm`. +4. `git merge upstream/main` on the feature branch. + +### 6.2 Conflict resolution policy +- **`src/constants.py`** β€” keep **both**: upstream's dynamic model-fetch logic **and** the fork's static `GEMINI_MODELS` list and env-driven `DEFAULT_MODEL`. This is the main conflict. +- **`src/main.py`** β€” accept upstream's dynamic `/v1/models` implementation; re-merge the fork's Gemini routing (`get_cli_for_model`) and both API surface paths (OpenAI chat completions + Anthropic Messages). +- **`src/models.py`** β€” accept upstream's dynamic-list changes; preserve the fork's `to_claude_options()` model passthrough and Claude-4 thinking-token default. +- **`pyproject.toml`** β€” both already at `2.3.0`; trivial. +- **`README.md`, `src/__init__.py`, `.env.example`, `tests/test_sdk_migration.py`** β€” take upstream, re-apply fork-specific wording. + +### 6.3 Verify (gate before Part 2) +- `pytest` is green on the merged tree. +- New upstream test `tests/test_dynamic_models.py` passes. + +--- + +## 7. Part 2 β€” SDK `0.1.18 β†’ 0.2.134` + parser hardening + +### 7.1 Dependency bump +- `pyproject.toml`: `claude-agent-sdk = ">=0.2.134,<0.3"`. +- `poetry lock && poetry install`. +- Confirm: `poetry show claude-agent-sdk` reports `0.2.134`. + +### 7.2 Parser hardening (in scope) +Replace the fragile generic objectβ†’dict conversion in `src/claude_cli.py:159-176` +(the `dir()`-walk that copies every public attribute) with typed checks: + +- Import `AssistantMessage`, `ResultMessage`, `SystemMessage`, `TextBlock` from `claude_agent_sdk`. +- In the message loop, branch on `isinstance(message, ...)` instead of the attribute walk. +- Preserve the existing field extraction contract used by `parse_claude_message()` and `extract_metadata()`: + - `ResultMessage` (`subtype == "success"`): `result`, `total_cost_usd`, `duration_ms`, `num_turns`, `session_id`, `usage`, `stop_reason`. + - `SystemMessage` (`subtype == "init"`): `session_id`, `model` (under `data`). + - `AssistantMessage`: `content` list of `TextBlock` (use `.text`). +- Keep a minimal fallback for any unexpected message shape so the wrapper never crashes on an unknown type. + +Behavior is unchanged for currently-working cases; robustness improves for future SDK field renames. + +### 7.3 Fix drift found during testing +Patch any field-name or shape differences between 0.1.18 and 0.2.134 discovered by the test suite. Expected low volume given the verified API stability. + +### 7.4 Docs +- `README.md`: update SDK version references `0.1.18 β†’ 0.2.134`. +- `docs/UPGRADE_PLAN.md`: mark the SDK-migration phase complete; record the new target version. + +### 7.5 Verify +- Full `pytest` suite green on `0.2.134`. +- Manual smoke: streaming response, session continuity (`session_id` resume), tool use (enable/disable). + +--- + +## 8. Part 3 β€” GLM-5.2 passthrough + +Passthrough already works (Section 5). Remaining work is advertisement and documentation. + +### 8.1 Code +- `src/constants.py`: add `GLM_MODELS = ["glm-5.2"]`. +- `src/main.py` `/v1/models` handler (~line 1338): advertise the union + `CLAUDE_MODELS + GEMINI_MODELS + GLM_MODELS` so `glm-5.2` is discoverable and + no "unknown model" warning fires. +- `DEFAULT_MODEL` is already env-driven (`src/constants.py:108`); document + `DEFAULT_MODEL=glm-5.2` for users who want GLM as the default. +- No change to `get_cli_for_model` (GLM already routes to `claude_cli`). +- No change to `validate_model` (already graceful). + +### 8.2 Docs +- New README section: **"Non-Claude models via passthrough (e.g. GLM-5.2)."** + Explain that the user points Claude Code at GLM by setting + `ANTHROPIC_BASE_URL` (and, if their proxy requires it, `ANTHROPIC_AUTH_TOKEN` / + `ANTHROPIC_API_KEY`) on the Claude Code process to a GLM-serving proxy. The + wrapper only forwards the model name to Claude Code; it does not configure the + endpoint. These env vars are the user's prerequisite, not wrapper code. + +### 8.3 Verify +- Unit test: `get_cli_for_model("glm-5.2")` returns the Claude CLI instance. +- Unit test: `glm-5.2` appears in the `/v1/models` response. +- Live smoke (user-run): against a Claude Code pointed at GLM, send a chat + completion with `model: "glm-5.2"` and confirm a response. + +--- + +## 9. Testing & verification summary + +| Layer | Check | +|---|---| +| Post-merge | `pytest` green; `test_dynamic_models.py` passes | +| Post-SDK-bump | `pytest` green on `0.2.134`; streaming + session + tool smoke | +| GLM | Unit: routing + model-list; live: GLM chat completion | +| New tests added | `glm-*` routing to `claude_cli`; `glm-5.2` in `/v1/models`; dynamic list still populates after merge | + +--- + +## 10. Rollback + +All work is on `feat/sync-sdk-upgrade-glm`. If any part fails badly: +- Abandon the branch (`git checkout main && git branch -D feat/sync-sdk-upgrade-glm`). +- Revert `pyproject.toml` to `claude-agent-sdk = "^0.1.18"` and `poetry lock && poetry install`. +- `main` is untouched until the PR merges; no production impact during development. + +--- + +## 11. Risks + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| `constants.py` merge conflict is messy | High | Low | Documented resolution policy (Section 6.2); merge on an isolated branch | +| SDK 0.2.134 message-field drift breaks parsing | Medium | Medium | Typed-parser rewrite + full test suite; fallback path preserved | +| Dynamic model list (upstream) excludes GLM/Gemini | Medium | Low | Static `GLM_MODELS`/`GEMINI_MODELS` appended to the advertised union | +| GLM backend env misconfigured by user | Medium | Low | README documents prerequisites; wrapper-side change is independent of user env | + +--- + +## 12. Out of scope + +- Direct GLM backend (bypassing Claude Code). +- CLI version change beyond `2.1.226`. +- Adopting `ClaudeSDKClient`, hooks, or in-process MCP from SDK 0.2.x (future work). +- Rebasing / force-pushing. diff --git a/examples/interactive_chat.py b/examples/interactive_chat.py new file mode 100644 index 0000000..9fc36fa --- /dev/null +++ b/examples/interactive_chat.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +Interactive Chat Client for Claude Code OpenAI Wrapper +Starts the server in the background and provides a rich TUI for chatting. +""" + +import subprocess +import time +import os +import signal +import sys +import httpx +from openai import OpenAI +from rich.console import Console +from rich.markdown import Markdown +from rich.live import Live +from rich.panel import Panel +from rich.prompt import Prompt + +# Configuration +DEFAULT_PORT = 8000 +API_KEY = os.getenv("API_KEY", "dev-token-123") # Pre-set key to bypass interactive prompt + + +def new_session_id(): + """Create a fresh session id for the wrapper.""" + return f"chat-{int(time.time() * 1000)}" + +def find_available_port(start_port): + import socket + port = start_port + while port < start_port + 10: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + if s.connect_ex(('localhost', port)) != 0: + return port + port += 1 + return start_port + +def start_server(port): + """Start the API server as a background process.""" + console = Console() + console.print(f"πŸš€ [bold blue]Starting server on port {port}...[/bold blue]") + + env = os.environ.copy() + env["API_KEY"] = API_KEY + env["PORT"] = str(port) + env["DEBUG_MODE"] = "false" + + # Try to use poetry run if available + try: + subprocess.run(["poetry", "--version"], capture_output=True, check=True) + cmd = ["poetry", "run", "python", "-m", "src.main", str(port)] + except (subprocess.CalledProcessError, FileNotFoundError): + cmd = [sys.executable, "-m", "src.main", str(port)] + + # Use start_new_session to make it a process group leader + process = subprocess.Popen( + cmd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True + ) + + # Wait for the health check + health_url = f"http://localhost:{port}/health" + max_wait = 30 + start_time = time.time() + + with console.status("[bold green]Waiting for server to initialize...[/bold green]") as status: + while time.time() - start_time < max_wait: + if process.poll() is not None: + # Process died + out, _ = process.communicate() + console.print(f"[bold red]Server failed to start:[/bold red]\n{out}") + sys.exit(1) + try: + resp = httpx.get(health_url, timeout=1.0) + if resp.status_code == 200: + console.print(f"βœ… [bold green]Server is ready at http://localhost:{port}[/bold green]") + return process + except (httpx.ConnectError, httpx.RequestError): + pass + time.sleep(1) + + process.terminate() + console.print("[bold red]Timeout waiting for server to start.[/bold red]") + sys.exit(1) + +def chat_loop(client, default_model): + """Main interactive chat loop.""" + console = Console() + console.print(Panel.fit( + "[bold green]Welcome to the Claude-Gemini Interactive Chat![/bold green]\n" + "Features: Background Server, Streaming, Markdown Rendering\n\n" + "Commands:\n" + " [bold cyan]/model[/bold cyan] - Change the model\n" + " [bold cyan]/clear[/bold cyan] - Clear conversation history\n" + " [bold cyan]/exit[/bold cyan] - Quit the chat", + title="Settings" + )) + + messages = [] + current_model = default_model + session_id = new_session_id() + + while True: + try: + user_input = Prompt.ask(f"\n[bold blue]({current_model}) You[/bold blue]") + + if not user_input.strip(): + continue + + if user_input.lower() in ["/exit", "exit", "quit"]: + break + + if user_input.startswith("/model"): + parts = user_input.split() + if len(parts) > 1: + current_model = parts[1] + messages = [] + session_id = new_session_id() + console.print( + f"πŸ”„ Model changed to [bold cyan]{current_model}[/bold cyan] " + "and conversation reset." + ) + else: + console.print("[yellow]Usage: /model [/yellow]") + console.print("[dim]Example: /model gemini-3-pro-preview[/dim]") + continue + + if user_input == "/clear": + messages = [] + session_id = new_session_id() + console.print("✨ Conversation history cleared. Started a new session.") + continue + + messages.append({"role": "user", "content": user_input}) + + console.print("\n[bold magenta]Assistant[/bold magenta]") + + full_response = "" + with Live(Markdown(""), refresh_per_second=10, console=console) as live: + try: + stream = client.chat.completions.create( + model=current_model, + messages=messages, + stream=True, + extra_body={"session_id": session_id} + ) + + for chunk in stream: + if chunk.choices[0].delta.content: + full_response += chunk.choices[0].delta.content + live.update(Markdown(full_response)) + except Exception as e: + live.update(f"[bold red]Error:[/bold red] {str(e)}") + continue + + messages.append({"role": "assistant", "content": full_response}) + + except KeyboardInterrupt: + console.print("\n[yellow]Interrupted. Type 'exit' to quit.[/yellow]") + continue + except EOFError: + break + +if __name__ == "__main__": + port = find_available_port(DEFAULT_PORT) + server_proc = None + + try: + server_proc = start_server(port) + + client = OpenAI( + base_url=f"http://localhost:{port}/v1", + api_key=API_KEY + ) + + # Default to Claude unless specified + default_model = os.getenv("DEFAULT_MODEL", "claude-sonnet-4-6") + + chat_loop(client, default_model) + + finally: + if server_proc: + print("\nπŸ›‘ Shutting down server...") + # Kill the whole process group + try: + os.killpg(os.getpgid(server_proc.pid), signal.SIGTERM) + except Exception: + server_proc.terminate() + print("Done.") diff --git a/poetry.lock b/poetry.lock index 03d8e92..4bf5906 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -406,26 +406,30 @@ files = [ [[package]] name = "claude-agent-sdk" -version = "0.1.18" +version = "0.2.135" description = "Python SDK for Claude Code" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "claude_agent_sdk-0.1.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9e45b4e3c20c072c3e3325fa60bab9a4b5a7cbbce64ca274b8d7d0af42dd9dd8"}, - {file = "claude_agent_sdk-0.1.18-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:3c41bd8f38848609ae0d5da8d7327a4c2d7057a363feafb6fd70df611ea204cc"}, - {file = "claude_agent_sdk-0.1.18-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:983f15e51253f40c55136a86d7cc63e023a3576428b05fa1459093d461b2d215"}, - {file = "claude_agent_sdk-0.1.18-py3-none-win_amd64.whl", hash = "sha256:36f5b84d5c3c8773ee9b56aeb5ab345d1033231db37f80d1f20ac15239bef41c"}, - {file = "claude_agent_sdk-0.1.18.tar.gz", hash = "sha256:4fcb8730cc77dea562fbe9aa48c65eced3ef58a6bb1f34f77e50e8258902477d"}, + {file = "claude_agent_sdk-0.2.135-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d087fa7f0c771b94cce23720661221e2bf131094dbe910ac36b840ab8438f4c8"}, + {file = "claude_agent_sdk-0.2.135-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:212eb1f30eebc38b6f7cbe51237a3b32264416b5db5d157951158ee98310f5a5"}, + {file = "claude_agent_sdk-0.2.135-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:5306f142ea018eca519cb7d0c3d7b4e97702ae009285d9e1c4896357fe87e27c"}, + {file = "claude_agent_sdk-0.2.135-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:01a4ded2b5b19edf2395ab77a38ce0d0e6cc0d6e1a263f85c701de5eb3764e20"}, + {file = "claude_agent_sdk-0.2.135-py3-none-win_amd64.whl", hash = "sha256:a85cc89e4b179c82bad95aa13362ca4302c425a811a3c3de5c142c2fc38f8567"}, + {file = "claude_agent_sdk-0.2.135.tar.gz", hash = "sha256:471ae3769d7814c658fa0a37dbd95bb4b1e365563d6a794dcdd99586a29ec53b"}, ] [package.dependencies] anyio = ">=4.0.0" -mcp = ">=0.1.0" +mcp = ">=1.23.0,<2.0.0" +sniffio = ">=1.0.0" typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} [package.extras] -dev = ["anyio[trio] (>=4.0.0)", "mypy (>=1.0.0)", "pytest (>=7.0.0)", "pytest-asyncio (>=0.20.0)", "pytest-cov (>=4.0.0)", "ruff (>=0.1.0)"] +dev = ["anyio[trio] (>=4.0.0)", "mypy (>=1.0.0)", "pytest (>=7.0.0)", "pytest-cov (>=4.0.0)", "ruff (>=0.1.0)"] +examples = ["asyncpg (>=0.27.0)", "boto3 (>=1.28.0)", "fakeredis (>=2.20.0)", "moto[s3] (>=5.0.0)", "redis (>=4.2.0)"] +otel = ["opentelemetry-api (>=1.20.0)"] [[package]] name = "click" @@ -1065,7 +1069,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -1350,19 +1354,20 @@ tests = ["pytest", "simplejson"] [[package]] name = "mcp" -version = "1.20.0" +version = "1.27.2" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] +markers = "python_version >= \"3.14\"" files = [ - {file = "mcp-1.20.0-py3-none-any.whl", hash = "sha256:d0dc06f93653f7432ff89f694721c87f79876b6f93741bf628ad1e48f7ac5e5d"}, - {file = "mcp-1.20.0.tar.gz", hash = "sha256:9ccc09eaadbfbcbbdab1c9723cfe2e0d1d9e324d7d3ce7e332ef90b09ed35177"}, + {file = "mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5"}, + {file = "mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef"}, ] [package.dependencies] anyio = ">=4.5" -httpx = ">=0.27.1" +httpx = ">=0.27.1,<1.0.0" httpx-sse = ">=0.4" jsonschema = ">=4.20.0" pydantic = ">=2.11.0,<3.0.0" @@ -1372,6 +1377,42 @@ python-multipart = ">=0.0.9" pywin32 = {version = ">=310", markers = "sys_platform == \"win32\""} sse-starlette = ">=1.6.1" starlette = ">=0.27" +typing-extensions = ">=4.9.0" +typing-inspection = ">=0.4.1" +uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""} + +[package.extras] +cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"] +rich = ["rich (>=13.9.4)"] +ws = ["websockets (>=15.0.1)"] + +[[package]] +name = "mcp" +version = "1.29.0" +description = "Model Context Protocol SDK" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version < \"3.14\"" +files = [ + {file = "mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7"}, + {file = "mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36"}, +] + +[package.dependencies] +anyio = ">=4.5" +httpx = ">=0.27.1,<1.0.0" +httpx-sse = ">=0.4" +jsonschema = ">=4.20.0" +pydantic = {version = ">=2.11.0,<3.0.0", markers = "python_version < \"3.14\""} +pydantic-settings = ">=2.5.2" +pyjwt = {version = ">=2.10.1", extras = ["crypto"]} +python-multipart = ">=0.0.9" +pywin32 = {version = ">=310", markers = "sys_platform == \"win32\" and python_version < \"3.14\""} +sse-starlette = ">=1.6.1" +starlette = {version = ">=0.27", markers = "python_version < \"3.14\""} +typing-extensions = ">=4.9.0" +typing-inspection = ">=0.4.1" uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""} [package.extras] @@ -1591,21 +1632,21 @@ files = [ [[package]] name = "pydantic" -version = "2.11.7" +version = "2.13.0b2" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, - {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, + {file = "pydantic-2.13.0b2-py3-none-any.whl", hash = "sha256:42a3dee97ad2b50b7489ad4fe8dfec509cb613487da9a3c19d480f0880e223bc"}, + {file = "pydantic-2.13.0b2.tar.gz", hash = "sha256:255b95518090cd7090b605ef975957b07f724778f71dafc850a7442e088e7b99"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.33.2" -typing-extensions = ">=4.12.2" -typing-inspection = ">=0.4.0" +pydantic-core = "2.42.0" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" [package.extras] email = ["email-validator (>=2.0.0)"] @@ -1613,115 +1654,129 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.42.0" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, - {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, - {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, - {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, - {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, - {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, - {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, - {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, - {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, - {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, - {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, - {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, - {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, - {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, - {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, - {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, - {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, - {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, - {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, - {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, - {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, - {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, - {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, - {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, + {file = "pydantic_core-2.42.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:0ae7d50a47ada2a04f7296be9a7a2bf447118a25855f41fc52c8fc4bfb70c105"}, + {file = "pydantic_core-2.42.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c9d04d4bd8de1dcd5c8845faf6c11e36cda34c2efffa29d70ad83cc6f6a6c9a8"}, + {file = "pydantic_core-2.42.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e459e89453bb1bc69853272260afb5328ae404f854ddec485f5427fbace8d7e"}, + {file = "pydantic_core-2.42.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:def66968fbe20274093fd4fc85d82b2ec42dbe20d9e51d27bbf3b5c7428c7a10"}, + {file = "pydantic_core-2.42.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:272fab515dc7da0f456c49747b87b4e8721a33ab352a54760cc8fd1a4fd5348a"}, + {file = "pydantic_core-2.42.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa82dec59f36106738ae981878e0001074e2b3a949f21a5b3bea20485b9c6db4"}, + {file = "pydantic_core-2.42.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a70fe4db00ab03a9f976d28471c8e696ebd3b8455ccfa5e36e5d1a2ff301a7"}, + {file = "pydantic_core-2.42.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b4c0f656b4fa218413a485c550ac3e4ddf2f343a9c46b6137394bd77c4128445"}, + {file = "pydantic_core-2.42.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a4396ffc8b42499d14662f958b3f00656b62a67bde7f156580fd618827bebf5a"}, + {file = "pydantic_core-2.42.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:36067825f365a5c3065f17d08421a72b036ff4588c450afe54d5750b80cc220d"}, + {file = "pydantic_core-2.42.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eec64367de940786c0b686d47bd952692018dd7cd895027aa82023186e469b7d"}, + {file = "pydantic_core-2.42.0-cp310-cp310-win32.whl", hash = "sha256:ff9f0737f487277721682d8518434557cfcef141ba55b89381c92700594a8b65"}, + {file = "pydantic_core-2.42.0-cp310-cp310-win_amd64.whl", hash = "sha256:77f0a8ab035d3bc319b759d8215f51846e9ea582dacbabb2777e5e3e135a048e"}, + {file = "pydantic_core-2.42.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1159b9ee73511ae7c5631b108d80373577bc14f22d18d85bb2aa1fa1051dabc"}, + {file = "pydantic_core-2.42.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff8e49b22225445d3e078aaa9bead90c37c852aee8f8a169ba15fdaaa13d1ecb"}, + {file = "pydantic_core-2.42.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe777d9a1a932c6b3ef32b201985324d06d9c74028adef1e1c7ea226fca2ba34"}, + {file = "pydantic_core-2.42.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e92592c1040ed17968d603e05b72acec321662ef9bf88fef443ceae4d1a130c2"}, + {file = "pydantic_core-2.42.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:557a6eb6dc4db8a3f071929710feb29c6b5d7559218ab547a4e60577fb404f2f"}, + {file = "pydantic_core-2.42.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4035f81e7d1a5e065543061376ca52ccb0accaf970911ba0a9ec9d22062806ca"}, + {file = "pydantic_core-2.42.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63a4e073f8def1c7fd100a355b3a96e1bbaf0446b6a8530ae58f1afaa0478a46"}, + {file = "pydantic_core-2.42.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dd8469c8d9f6c81befd10c72a0268079e929ba494cd27fa63e868964b0e04fb6"}, + {file = "pydantic_core-2.42.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bdebfd610a02bdb82f8e36dc7d4683e03e420624a2eda63e1205730970021308"}, + {file = "pydantic_core-2.42.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9577eb5221abd4e5adf8a232a65f74c509b82b57b7b96b3667dac22f03ff9e94"}, + {file = "pydantic_core-2.42.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c6d36841b61100128c2374341a7c2c0ab347ef4b63aa4b6837b4431465d4d4fd"}, + {file = "pydantic_core-2.42.0-cp311-cp311-win32.whl", hash = "sha256:1d9d45333a28b0b8fb8ecedf67d280dc3318899988093e4d3a81618396270697"}, + {file = "pydantic_core-2.42.0-cp311-cp311-win_amd64.whl", hash = "sha256:4631b4d1a3fe460aadd3822af032bb6c2e7ad77071fbf71c4e95ef9083c7c1a8"}, + {file = "pydantic_core-2.42.0-cp311-cp311-win_arm64.whl", hash = "sha256:3d46bfc6175a4b4b80b9f98f76133fbf68d5a02d7469b3090ca922d40f23d32d"}, + {file = "pydantic_core-2.42.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a11b9115364681779bcc39c6b9cdc20d48a9812a4bf3ed986fec4f694ed3a1e7"}, + {file = "pydantic_core-2.42.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c43088e8a44ccb2a2329d83892110587ebe661090b546dd03624a933fc4cfd0d"}, + {file = "pydantic_core-2.42.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13a7f9dde97c8400de559b2b2dcd9439f7b2b8951dad9b19711ef8c6e3f68ac0"}, + {file = "pydantic_core-2.42.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6380214c627f702993ea6b65b6aa8afc0f1481a179cdd169a2fc80a195e21158"}, + {file = "pydantic_core-2.42.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:606f80d8c61d4680ff82a34e9c49b7ab069b544b93393cc3c5906ac9e8eec7c9"}, + {file = "pydantic_core-2.42.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ab80ae93cb739de6c9ccc06a12cd731b079e1b25b03e2dcdccbc914389cc7e0"}, + {file = "pydantic_core-2.42.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:638f04b55bea04ec5bbda57a4743a51051f24b884abcb155b0ed2c3cb59ba448"}, + {file = "pydantic_core-2.42.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec72ba5c7555f69757b64b398509c7079fb22da705a6c67ac613e3f14a05f729"}, + {file = "pydantic_core-2.42.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e0364f6cd61be57bcd629c34788c197db211e91ce1c3009bf4bf97f6bb0eb21f"}, + {file = "pydantic_core-2.42.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:856f0fd81173b308cd6ceb714332cd9ea3c66ce43176c7defaed6b2ed51d745c"}, + {file = "pydantic_core-2.42.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1be705396e480ea96fd3cccd7512affda86823b8a2a8c196d9028ec37cb1ca77"}, + {file = "pydantic_core-2.42.0-cp312-cp312-win32.whl", hash = "sha256:acacf0795d68e42d01ae8cc77ae19a5b3c80593e0fd60e4e2d336ec13d3de906"}, + {file = "pydantic_core-2.42.0-cp312-cp312-win_amd64.whl", hash = "sha256:475a1a5ecf3a748a0d066b56138d258018c8145873ee899745c9f0e0af1cc4d4"}, + {file = "pydantic_core-2.42.0-cp312-cp312-win_arm64.whl", hash = "sha256:e2369cef245dd5aeafe6964cf43d571fb478f317251749c152c0ae564127053a"}, + {file = "pydantic_core-2.42.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:02fd2b4a62efa12e004fce2bfd2648cf8c39efc5dfc5ed5f196eb4ccefc7db4e"}, + {file = "pydantic_core-2.42.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c042694870c20053b8814a57c416cd2c6273fe462a440460005c791c24c39baf"}, + {file = "pydantic_core-2.42.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f905f3a082e7498dfaa70c204b236e92d448ba966ad112a96fcaaba2c4984fba"}, + {file = "pydantic_core-2.42.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4762081e8acc5458bf907373817cf93c927d451a1b294c1d0535b0570890d939"}, + {file = "pydantic_core-2.42.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4a433bbf6304bd114b96b0ce3ed9add2ee686df448892253bca5f622c030f31"}, + {file = "pydantic_core-2.42.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd695305724cfce8b19a18e87809c518f56905e5c03a19e3ad061974970f717d"}, + {file = "pydantic_core-2.42.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5f352ffa0ec2983b849a93714571063bfc57413b5df2f1027d7a04b6e8bdd25"}, + {file = "pydantic_core-2.42.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e61f2a194291338d76307a29e4881a8007542150b750900c1217117fc9bb698e"}, + {file = "pydantic_core-2.42.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:032f990dc1759f11f6b287e5c6eb1b0bcfbc18141779414a77269b420360b3bf"}, + {file = "pydantic_core-2.42.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:9c28b42768da6b9238554ae23b39291c3bbe6f53c4810aea6414d83efd59b96a"}, + {file = "pydantic_core-2.42.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:b22af1ac75fa873d81a65cce22ada1d840583b73a129b06133097c81f6f9e53b"}, + {file = "pydantic_core-2.42.0-cp313-cp313-win32.whl", hash = "sha256:1de0350645c8643003176659ee70b637cd80e8514a063fff36f088fcda2dba06"}, + {file = "pydantic_core-2.42.0-cp313-cp313-win_amd64.whl", hash = "sha256:d34b481a8a3eba3678a96e166c6e547c0c8b026844c13d9deb70c9f1fd2b0979"}, + {file = "pydantic_core-2.42.0-cp313-cp313-win_arm64.whl", hash = "sha256:5e0a65358eef041d95eef93fcf8834c2c8b83cc5a92d32f84bb3a7955dfe21c9"}, + {file = "pydantic_core-2.42.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:de4c9ad4615983b3fb2ee57f5c570cf964bda13353c6c41a54dac394927f0e54"}, + {file = "pydantic_core-2.42.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:129d5e6357814e4567e18b2ded4c210919aafd9ef0887235561f8d853fd34123"}, + {file = "pydantic_core-2.42.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4c45582a5dac4649e512840ad212a5c2f9d168622f8db8863e8a29b54a29dfd"}, + {file = "pydantic_core-2.42.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a97fc19afb730b45de55d2e80093f1a36effc29538dec817204c929add8f2b4a"}, + {file = "pydantic_core-2.42.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e45d83d38d94f22ffe9a0f0393b23e25bfefe4804ae63c8013906b76ab8de8ed"}, + {file = "pydantic_core-2.42.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3060192d8b63611a2abb26eccadddff5602a66491b8fafd9ae34fb67302ae84"}, + {file = "pydantic_core-2.42.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f17739150af9dc58b5c8fc3c4a1826ff84461f11b9f8ad5618445fcdd1ccec6"}, + {file = "pydantic_core-2.42.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d14e4c229467a7c27aa7c71e21584b3d77352ccb64e968fdbed4633373f73f7"}, + {file = "pydantic_core-2.42.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:aaef75e1b54366c7ccfbf4fc949ceaaa0f4c87e106df850354be6c7d45143db0"}, + {file = "pydantic_core-2.42.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:d2e362dceeeb4d56fd63e649c2de3ad4c3aa448b13ab8a9976e23a669f9c1854"}, + {file = "pydantic_core-2.42.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:a8edee724b527818bf0a6c8e677549794c0d0caffd14492851bd7a4ceab0f258"}, + {file = "pydantic_core-2.42.0-cp314-cp314-win32.whl", hash = "sha256:a10c105c221f68221cb81be71f063111172f5ddf8b06f6494560e826c148f872"}, + {file = "pydantic_core-2.42.0-cp314-cp314-win_amd64.whl", hash = "sha256:232d86e00870aceee7251aa5f4ab17e3e4864a4656c015f8e03d1223bf8e17ba"}, + {file = "pydantic_core-2.42.0-cp314-cp314-win_arm64.whl", hash = "sha256:9a6fce4e778c2fe2b3f1df63bfaa522c147668517ba040c49ad7f67a66867cff"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f4d1670fbc5488cfb18dd9fc71a2c7c8e12caeeb6e5bb641aa351ac5e01963cf"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:baeae16666139d0110f1006a06809228f5293ab84e77f4b9dda2bdee95d6c4e8"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a77c7a8cedf5557a4e5547dabf55a8ec99949162bd7925b312f6ec37c24101c"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:133fccf13546ff2a0610cc5b978dd4ee2c7f55a7a86b6b722fd6e857694bacc5"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad5dbebfbab92cf0f6d0b13d55bf0a239880a1534377edf6387e2e7a4469f131"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6c0181016cb29ba4824940246606a8e13b1135de8306e00b5bd9d1efbc4cf85"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:020cfd7041cb71eac4dc93a29a6d5ec34f10b1fdc37f4f189c25bcc6748a2f97"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f73c6de3ee24f2b614d344491eda5628c4cdf3e7b79c0ac69bb40884ced2d319"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:b2b448da50e1e8d5aac786dcf441afa761d26f1be4532b52cdf50864b47bd784"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:0df0488b1f548ef874b45bbc60a70631eee0177b79b5527344d7a253e77a5ed2"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:b8aa32697701dc36c956f4a78172549adbe25eacba952bbfbde786fb66316151"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-win32.whl", hash = "sha256:173de56229897ff81b650ca9ed6f4c62401c49565234d3e9ae251119f6fd45c6"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2db227cf6797c286361f8d1e52b513f358a3ff9ebdede335e55a5edf4c59f06b"}, + {file = "pydantic_core-2.42.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a983862733ecaf0b5c7275145f86397bde4ee1ad84cf650e1d7af7febe5f7073"}, + {file = "pydantic_core-2.42.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fc0834a2d658189c89d7a009ae19462da1d70fc4786d2b8e5c8c6971f4d3bcc1"}, + {file = "pydantic_core-2.42.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ff69cf1eb517600d40c903dbc3507360e0a6c1ffa2dcf3cfa49a1c6fe203a46a"}, + {file = "pydantic_core-2.42.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3eab236da1c53a8cdf741765e31190906eb2838837bfedcaa6c0206b8f5975e"}, + {file = "pydantic_core-2.42.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15df82e324fa5b2b1403d5eb1bb186d14214c3ce0aebc9a3594435b82154d402"}, + {file = "pydantic_core-2.42.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ee7047297892d4fec68658898b7495be8c1a8a2932774e2d6810c3de1173783"}, + {file = "pydantic_core-2.42.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aec13272d859be1dd3344b75aab4d1d6690bfef78bd241628f6903c2bf101f8d"}, + {file = "pydantic_core-2.42.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e7adfd7794da8ae101d2d5e6a7be7cb39bb90d45b6aa42ecb502a256e94f8e0"}, + {file = "pydantic_core-2.42.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0e3cfcacb42193479ead3aaba26a79e7df4c1c2415aefc43f1a60b57f50f8aa4"}, + {file = "pydantic_core-2.42.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cf89cee72f88db54763f800d32948bd6b1b9bf03e0ecb0a9cb93eac513caec5f"}, + {file = "pydantic_core-2.42.0-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:c6ae4c08e6c4b08e35eb2b114803d09c5012602983d8bbd3564013d555dfe5fd"}, + {file = "pydantic_core-2.42.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:dfedd24ce01a3ea32f29c257e5a7fc79ed635cff0bd1a1aed12a22d3440cb39f"}, + {file = "pydantic_core-2.42.0-cp39-cp39-win32.whl", hash = "sha256:26ab24eecdec230bdf7ec519b9cd0c65348ec6e97304e87f9d3409749ea3377b"}, + {file = "pydantic_core-2.42.0-cp39-cp39-win_amd64.whl", hash = "sha256:f93228d630913af3bc2d55a50a96e0d33446b219aea9591bfdc0a06677f689ff"}, + {file = "pydantic_core-2.42.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:53ab90bed3a191750a6726fe2570606a9794608696063823d2deea734c100bf6"}, + {file = "pydantic_core-2.42.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:b8d9911a3cdb8062f4102499b666303c9a976202b420200a26606eafa0bfecf8"}, + {file = "pydantic_core-2.42.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe6b7b22dd1d326a1ab23b9e611a69c41d606cb723839755bb00456ebff3f672"}, + {file = "pydantic_core-2.42.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5e36849ca8e2e39828a70f1a86aa2b86f645a1d710223b6653f2fa8a130b703"}, + {file = "pydantic_core-2.42.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4d7e36c2a1f3c0020742190714388884a11282a0179f3d1c55796ee26b32dba5"}, + {file = "pydantic_core-2.42.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:41a702c2ac3dbbafa7d13bea142b3e04c8676d1fca199bac52b5ee24e6cdb737"}, + {file = "pydantic_core-2.42.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad5cb8ed96ffac804a0298f5d03f002769514700d79cbe77b66a27a6e605a65a"}, + {file = "pydantic_core-2.42.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51e33cf940cddcad333f85e15a25a2a949ac0a7f26fe8f43dc2d6816ce974ec4"}, + {file = "pydantic_core-2.42.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:495e70705f553c3b8f939965fa7cf77825c81417ff3c7ac046be9509b94c292c"}, + {file = "pydantic_core-2.42.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8757702cc696d48f9fdcb65cb835ca18bda5d83169fe6d13efd706e4195aea81"}, + {file = "pydantic_core-2.42.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32cc3087f38e4a9ee679f6184670a1b6591b8c3840c483f3342e176e215194d1"}, + {file = "pydantic_core-2.42.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e824d8f372aa717eeb435ee220c8247e514283a4fc0ecdc4ce44c09ee485a5b8"}, + {file = "pydantic_core-2.42.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e5900b257abb20371135f28b686d6990202dcdd9b7d8ff2e2290568aa0058280"}, + {file = "pydantic_core-2.42.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:f6705c73ab2abaebef81cad882a75afd6b8a0550e853768933610dce2945705e"}, + {file = "pydantic_core-2.42.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5ed95136324ceef6f33bd96ee3a299d36169175401204590037983aeb5bc73de"}, + {file = "pydantic_core-2.42.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9d729a3934e0ef3bc171025f0414d422aa6397d6bbd8176d5402739140e50616"}, + {file = "pydantic_core-2.42.0.tar.gz", hash = "sha256:34068adadf673c872f01265fa17ec00073e99d7f53f6d499bdfae652f330b3d2"}, ] [package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +typing-extensions = ">=4.14.1" [[package]] name = "pydantic-settings" @@ -2640,26 +2695,26 @@ typing-extensions = ">=3.7.4.3" [[package]] name = "typing-extensions" -version = "4.14.0" +version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, - {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, - {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, ] [package.dependencies] @@ -3053,4 +3108,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "995cbb6b6bfbf14612eff7e0690ca47fc7b0c01fd2ef3351dea01d6940be0ed6" +content-hash = "59d9ec5d56d879f1073e82e0a3ee0355d0f46ab2cb227b6df40512a6768c6ac7" diff --git a/pyproject.toml b/pyproject.toml index dcc6fe5..f6c33bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ python-dotenv = "^1.0.1" httpx = "^0.27.2" sse-starlette = "^2.1.3" python-multipart = "^0.0.18" -claude-agent-sdk = "^0.1.18" +claude-agent-sdk = ">=0.2.134,<0.3" slowapi = "^0.1.9" [tool.poetry.group.dev.dependencies] @@ -35,6 +35,17 @@ hypothesis = "^6.122.0" requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" +[tool.pytest.ini_options] +asyncio_mode = "strict" +pythonpath = ["."] +filterwarnings = [ + # anyio on Python 3.14: 'return' inside a 'finally' block (dependency-internal). + "ignore:'return' inside a 'finally' block:SyntaxWarning", + # asyncio event-loop deprecations surfaced by the test runner / deps on 3.14. + "ignore:.*asyncio.*:DeprecationWarning", + "ignore:.*get_event_loop.*:DeprecationWarning", +] + [tool.black] line-length = 100 target-version = ['py310'] diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..2641471 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -e + +VENV_DIR=".venv" + +python3 -m venv "$VENV_DIR" +source "$VENV_DIR/bin/activate" + +pip install --upgrade pip +pip install -e ".[dev]" 2>/dev/null || pip install -e . + +echo "Done. To activate: source $VENV_DIR/bin/activate" diff --git a/scripts/start_server.sh b/scripts/start_server.sh new file mode 100755 index 0000000..a1eaa2b --- /dev/null +++ b/scripts/start_server.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +screen -dmS claude-wrapper bash -c " + source '$PROJECT_DIR/.venv/bin/activate' + cd '$PROJECT_DIR' + python -m uvicorn src.main:app --host 0.0.0.0 --port 6969 +" + +echo "Server started in screen session 'claude-wrapper' on port 6969" +echo "Attach with: screen -r claude-wrapper" diff --git a/scripts/test_message.py b/scripts/test_message.py new file mode 100644 index 0000000..f543e0f --- /dev/null +++ b/scripts/test_message.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +Start the wrapper server, send a message, print the response, and shut down. + +Usage: + python scripts/test_message.py "What is the capital of France?" + python scripts/test_message.py --max-tokens 1024 "Write a haiku" +""" + +import argparse +import os +import sys +import time +import subprocess + +import httpx + +SERVER_URL = "http://localhost:8000" +MODEL = "claude-sonnet-4-5-20250929" +API_KEY = "test" + + +def wait_for_server(timeout: int = 30) -> None: + for _ in range(timeout): + try: + httpx.get(f"{SERVER_URL}/health", timeout=2).raise_for_status() + return + except Exception: + time.sleep(1) + raise TimeoutError(f"Server did not become ready within {timeout}s") + + +def send_message(message: str, max_tokens: int) -> str: + with httpx.Client() as client: + response = client.post( + f"{SERVER_URL}/v1/messages", + headers={"Authorization": f"Bearer {API_KEY}"}, + json={ + "model": MODEL, + "messages": [{"role": "user", "content": message}], + "max_tokens": max_tokens, + }, + timeout=60, + ) + response.raise_for_status() + return response.json()["content"][0]["text"] + + +def main() -> None: + parser = argparse.ArgumentParser(description="Send a message to the Claude wrapper server.") + parser.add_argument("message", help="The message to send") + parser.add_argument( + "--max-tokens", type=int, default=4096, help="Maximum tokens to generate (default: 4096)" + ) + args = parser.parse_args() + + env = {**os.environ, "API_KEY": API_KEY, "DEBUG_MODE": "true"} + + server = subprocess.Popen( + [sys.executable, "-m", "src.main"], + env=env, + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + stderr=sys.stderr, + stdout=sys.stderr, + ) + + try: + wait_for_server() + print(send_message(args.message, args.max_tokens)) + finally: + server.terminate() + server.wait() + + +if __name__ == "__main__": + main() diff --git a/src/auth.py b/src/auth.py index 7b23e69..cf492f1 100644 --- a/src/auth.py +++ b/src/auth.py @@ -66,6 +66,8 @@ def _detect_auth_method(self) -> str: return "vertex" elif os.getenv("ANTHROPIC_API_KEY"): return "anthropic" + elif os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY"): + return "gemini" else: # If no explicit method, assume Claude Code CLI is already authenticated return "claude_cli" @@ -83,8 +85,10 @@ def _validate_auth_method(self) -> Dict[str, Any]: status.update(self._validate_vertex_auth()) elif method == "claude_cli": status.update(self._validate_claude_cli_auth()) + elif method == "gemini": + status.update(self._validate_gemini_auth()) else: - status["errors"].append("No Claude Code authentication method configured") + status["errors"].append("No Claude Code or Gemini authentication method configured") return status @@ -169,6 +173,22 @@ def _validate_vertex_auth(self) -> Dict[str, Any]: return {"valid": len(errors) == 0, "errors": errors, "config": config} + def _validate_gemini_auth(self) -> Dict[str, Any]: + """Validate Gemini API key authentication.""" + api_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") + if not api_key: + return { + "valid": False, + "errors": ["Neither GEMINI_API_KEY nor GOOGLE_API_KEY environment variable is set"], + "config": {}, + } + + return { + "valid": True, + "errors": [], + "config": {"api_key_present": True, "api_key_length": len(api_key)}, + } + def _validate_claude_cli_auth(self) -> Dict[str, Any]: """Validate that Claude Code CLI is already authenticated.""" # For CLI authentication, we assume it's valid and let the SDK handle auth @@ -210,6 +230,12 @@ def get_claude_code_env_vars(self) -> Dict[str, str]: "GOOGLE_APPLICATION_CREDENTIALS" ) + elif self.auth_method == "gemini": + if os.getenv("GEMINI_API_KEY"): + env_vars["GEMINI_API_KEY"] = os.getenv("GEMINI_API_KEY") + if os.getenv("GOOGLE_API_KEY"): + env_vars["GOOGLE_API_KEY"] = os.getenv("GOOGLE_API_KEY") + elif self.auth_method == "claude_cli": # For CLI auth, don't set any environment variables # Let Claude Code SDK use the existing CLI authentication diff --git a/src/claude_cli.py b/src/claude_cli.py index d87057e..6cf4653 100644 --- a/src/claude_cli.py +++ b/src/claude_cli.py @@ -1,4 +1,5 @@ import os +import asyncio import tempfile import atexit import shutil @@ -6,11 +7,78 @@ from pathlib import Path import logging -from claude_agent_sdk import query, ClaudeAgentOptions +from claude_agent_sdk import ( + query, + ClaudeAgentOptions, + AssistantMessage, + ResultMessage, + SystemMessage, +) +from src.constants import CLAUDE_CLI_PATH, PASSTHROUGH_MODELS logger = logging.getLogger(__name__) +# Neutral system prompt for passthrough (non-Claude) models such as GLM-5.2, +# served via an ANTHROPIC_BASE_URL proxy. The claude_code preset is an agentic +# prompt that primes tool calls; feeding it to such a model makes it emit +# tool_use on turn 1, tripping the max_turns cap as a hard error instead of +# returning text. +NEUTRAL_SYSTEM_PROMPT = "You are a helpful assistant." + + +def _message_to_dict(message: Any) -> Dict[str, Any]: + """Normalize an SDK message into the dict shape the downstream parser expects. + + Uses typed isinstance checks against the SDK 0.2.x message classes so a + field rename does not silently break extraction. Dicts pass through + unchanged (e.g. injected error results). Unknown message types fall back to + copying public, non-callable attributes. + """ + if isinstance(message, dict): + return message + + if isinstance(message, ResultMessage): + return { + "type": "result", + "subtype": message.subtype, + "result": message.result, + "total_cost_usd": message.total_cost_usd, + "duration_ms": message.duration_ms, + "num_turns": message.num_turns, + "session_id": message.session_id, + "usage": message.usage, + "stop_reason": message.stop_reason, + "is_error": message.is_error, + } + + if isinstance(message, SystemMessage): + return { + "type": "system", + "subtype": message.subtype, + "data": message.data, + } + + if isinstance(message, AssistantMessage): + return { + "type": "assistant", + "content": list(message.content or []), + } + + # Generic fallback for any other SDK message type (UserMessage, etc.). + message_dict: Dict[str, Any] = {} + for attr_name in dir(message): + if attr_name.startswith("_"): + continue + try: + value = getattr(message, attr_name) + except Exception: + continue + if not callable(value): + message_dict[attr_name] = value + return message_dict or {"type": "unknown"} + + class ClaudeCodeCLI: def __init__(self, timeout: int = 600000, cwd: Optional[str] = None): self.timeout = timeout / 1000 # Convert ms to seconds @@ -51,30 +119,25 @@ def __init__(self, timeout: int = 600000, cwd: Optional[str] = None): # Store auth environment variables for SDK self.claude_env_vars = auth_manager.get_claude_code_env_vars() - async def verify_cli(self) -> bool: + async def verify_cli(self, prompt: str = "Hello") -> bool: """Verify Claude Agent SDK is working and authenticated.""" try: # Test SDK with a simple query - logger.info("Testing Claude Agent SDK...") + logger.info(f"Testing Claude Agent SDK with prewarm query: '{prompt}'...") messages = [] async for message in query( - prompt="Hello", + prompt=prompt, options=ClaudeAgentOptions( max_turns=1, cwd=self.cwd, + cli_path=CLAUDE_CLI_PATH, system_prompt={"type": "preset", "preset": "claude_code"}, ), ): messages.append(message) # Break early on first response to speed up verification - # Handle both dict and object types - msg_type = ( - getattr(message, "type", None) - if hasattr(message, "type") - else message.get("type") if isinstance(message, dict) else None - ) - if msg_type == "assistant": + if isinstance(message, AssistantMessage): break if messages: @@ -96,91 +159,83 @@ async def run_completion( self, prompt: str, system_prompt: Optional[str] = None, - model: Optional[str] = None, stream: bool = True, - max_turns: int = 10, - allowed_tools: Optional[List[str]] = None, - disallowed_tools: Optional[List[str]] = None, session_id: Optional[str] = None, continue_session: bool = False, - permission_mode: Optional[str] = None, + claude_options: Optional[Dict] = None, ) -> AsyncGenerator[Dict[str, Any], None]: """Run Claude Agent using the Python SDK and yield response chunks.""" + async for chunk in self._run_completion_inner( + prompt, system_prompt, stream, session_id, continue_session, claude_options + ): + yield chunk - try: - # Set authentication environment variables (if any) - original_env = {} - if self.claude_env_vars: # Only set env vars if we have any - for key, value in self.claude_env_vars.items(): - original_env[key] = os.environ.get(key) - os.environ[key] = value + async def _run_completion_inner( + self, + prompt: str, + system_prompt: Optional[str] = None, + stream: bool = True, + session_id: Optional[str] = None, + continue_session: bool = False, + claude_options: Optional[Dict] = None, + ) -> AsyncGenerator[Dict[str, Any], None]: + """Inner implementation of run_completion.""" - try: - # Build SDK options - options = ClaudeAgentOptions(max_turns=max_turns, cwd=self.cwd) - - # Set model if specified - if model: - options.model = model - - # Set system prompt - CLAUDE AGENT SDK STRUCTURED FORMAT - # Use structured format as per SDK documentation - if system_prompt: - options.system_prompt = {"type": "text", "text": system_prompt} - else: - # Use Claude Code preset to maintain expected behavior - options.system_prompt = {"type": "preset", "preset": "claude_code"} - - # Set tool restrictions - if allowed_tools: - options.allowed_tools = allowed_tools - if disallowed_tools: - options.disallowed_tools = disallowed_tools - - # Set permission mode (needed for tool execution in API context) - if permission_mode: - options.permission_mode = permission_mode - - # Handle session continuity - if continue_session: - options.continue_session = True - elif session_id: - options.resume = session_id - - # Run the query and yield messages + try: + # Build SDK options (default max_turns=10 for tool-enabled context) + options = ClaudeAgentOptions(max_turns=10, cwd=self.cwd, cli_path=CLAUDE_CLI_PATH) + + # Set system prompt. Pass a plain string so the SDK emits + # --system-prompt , which REPLACES the CLI default (the full + # claude_code agentic prompt β€” the bulk of the request tokens). + # + # SDK flag contract (subprocess_cli._build_command): only these shapes + # produce a flag β€” str -> --system-prompt; {"type":"file","path":...} + # -> --system-prompt-file; {"type":"preset",...,"append":...} -> + # --append-system-prompt. Any OTHER dict (including {"type":"text",...} + # and {"type":"preset","preset":"claude_code"} without "append") emits + # NO flag and silently falls back to the CLI default. So a literal text + # prompt MUST be a str, never a dict. + # + # An explicit caller-supplied prompt always wins. Without one, real + # Claude models keep the claude_code default; passthrough (non-Claude) + # models get a neutral prompt so the agentic default does not prime + # tool calls they cannot fulfill (which trips the max_turns cap). + model_name = (claude_options or {}).get("model") + if system_prompt: + options.system_prompt = system_prompt + elif model_name and model_name in PASSTHROUGH_MODELS: + options.system_prompt = NEUTRAL_SYSTEM_PROMPT + else: + # preset dict without "append" is a recognized no-op that leaves + # the CLI default (claude_code) in place. + options.system_prompt = {"type": "preset", "preset": "claude_code"} + + # Handle session continuity + if continue_session: + options.continue_conversation = True + elif session_id: + options.resume = session_id + + # Apply claude_options via generic setattr β€” handles model, max_turns, + # allowed_tools, disallowed_tools, permission_mode, max_thinking_tokens, + # effort, output_format, user, max_budget_usd, thinking, etc. + for key, value in (claude_options or {}).items(): + if value is not None and hasattr(options, key): + setattr(options, key, value) + + # Set authentication env vars directly on options (avoids os.environ mutation + # and the serializing lock that came with it β€” requests are now fully concurrent) + if self.claude_env_vars: + options.env = {**dict(os.environ), **self.claude_env_vars} + + # Run the query and yield messages (with timeout to prevent indefinite hang) + async with asyncio.timeout(self.timeout): 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 - - finally: - # Restore original environment (if we changed anything) - if original_env: - for key, original_value in original_env.items(): - if original_value is None: - os.environ.pop(key, None) - else: - os.environ[key] = original_value + yield _message_to_dict(message) except Exception as e: logger.error(f"Claude Agent SDK error: {e}") @@ -198,9 +253,17 @@ def parse_claude_message(self, messages: List[Dict[str, Any]]) -> Optional[str]: Prioritizes ResultMessage.result for multi-turn conversations, falls back to last AssistantMessage content. """ - # First, check for ResultMessage with 'result' field (multi-turn completion) + # First, check for ResultMessage with 'result' field (multi-turn completion). + # Skip error results (is_error=True) so upstream API errors such as + # 429/500/529 are not returned to the client as if they were a normal + # assistant reply β€” the SDK documents subtype="success" with is_error=True + # for these failing API calls. for message in messages: - if message.get("subtype") == "success" and "result" in message: + if ( + message.get("subtype") == "success" + and not message.get("is_error") + and "result" in message + ): return message["result"] # Collect all text from AssistantMessages (take the last one with text) @@ -240,13 +303,15 @@ def parse_claude_message(self, messages: List[Dict[str, Any]]) -> Optional[str]: return last_text def extract_metadata(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]: - """Extract metadata like costs, tokens, and session info from SDK messages.""" + """Extract metadata like costs, tokens, session info, and stop reason from SDK messages.""" metadata = { "session_id": None, "total_cost_usd": 0.0, "duration_ms": 0, "num_turns": 0, "model": None, + "usage": None, + "stop_reason": None, } for message in messages: @@ -258,6 +323,8 @@ def extract_metadata(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]: "duration_ms": message.get("duration_ms", 0), "num_turns": message.get("num_turns", 0), "session_id": message.get("session_id"), + "usage": message.get("usage"), + "stop_reason": message.get("stop_reason"), } ) # New SDK format - SystemMessage @@ -272,6 +339,8 @@ def extract_metadata(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]: "duration_ms": message.get("duration_ms", 0), "num_turns": message.get("num_turns", 0), "session_id": message.get("session_id"), + "usage": message.get("usage"), + "stop_reason": message.get("stop_reason"), } ) elif message.get("type") == "system" and message.get("subtype") == "init": @@ -281,6 +350,16 @@ def extract_metadata(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]: return metadata + @staticmethod + def map_stop_reason_openai(stop_reason: Optional[str]) -> str: + """Map Claude SDK stop_reason to OpenAI finish_reason.""" + if stop_reason == "max_tokens": + return "length" + elif stop_reason == "stop_sequence": + return "stop" + # "end_turn", None, or any unknown value β†’ "stop" + return "stop" + def estimate_token_usage( self, prompt: str, completion: str, model: Optional[str] = None ) -> Dict[str, int]: diff --git a/src/constants.py b/src/constants.py index 46fabca..7be70c0 100644 --- a/src/constants.py +++ b/src/constants.py @@ -101,6 +101,38 @@ async def chat_endpoint(): ... else DEFAULT_CLAUDE_MODELS ) +# Gemini Models +# Models supported by Gemini CLI (as of March 2026) +GEMINI_MODELS = [ + "gemini-3-pro-preview", + "gemini-3-flash-preview", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "pro", # Alias for gemini-3-pro-preview + "flash", # Alias for gemini-2.5-flash + "flash-lite", # Alias for gemini-2.5-flash-lite + "auto", # Alias for gemini-3-pro-preview (recommended) +] + +# GLM Models +# Served through Claude Code via a custom ANTHROPIC_BASE_URL proxy. The wrapper +# only forwards the model name; it never calls a GLM endpoint directly. +GLM_MODELS = [ + "glm-5.2", + "glm-5.2[1m]", +] + +# Non-Anthropic models advertised in /v1/models in addition to the live list. +# They never appear in Anthropic's live Models API response, so they are +# appended at the /v1/models edge (see _append_passthrough in main.py). +PASSTHROUGH_MODELS = GLM_MODELS + GEMINI_MODELS + +# Claude Code CLI binary the SDK invokes. Defaults to the local native install +# (/Users/gus/.local/bin/claude); override with CLAUDE_CLI_PATH to pin a specific +# build or version. +CLAUDE_CLI_PATH = os.getenv("CLAUDE_CLI_PATH", "/Users/gus/.local/bin/claude") + # Default model (recommended for most use cases) # DEFAULT_MODEL_ENV is the explicit operator override; when unset, the wrapper # resolves the latest Sonnet from Anthropic's live Models API at startup and @@ -115,14 +147,40 @@ async def chat_endpoint(): ... # Can be overridden via FAST_MODEL environment variable FAST_MODEL = os.getenv("FAST_MODEL", "claude-haiku-4-5-20251001") + +def _env_int(name: str, default: int) -> int: + """Parse an integer env var, returning `default` on empty/non-numeric input. + + A stray `NAME=` in .env or shell must not prevent the app from starting. + """ + raw = os.getenv(name) + if raw is None or raw.strip() == "": + return default + try: + return int(raw) + except (TypeError, ValueError): + return default + + +def _env_float(name: str, default: float) -> float: + """Parse a float env var, returning `default` on empty/non-numeric input.""" + raw = os.getenv(name) + if raw is None or raw.strip() == "": + return default + try: + return float(raw) + except (TypeError, ValueError): + return default + + # Anthropic Models API configuration for dynamically refreshing /v1/models ANTHROPIC_MODELS_URL = os.getenv("ANTHROPIC_MODELS_URL", "https://api.anthropic.com/v1/models") ANTHROPIC_VERSION = os.getenv("ANTHROPIC_VERSION", "2023-06-01") -MODEL_LIST_CACHE_TTL_SECONDS = int(os.getenv("MODEL_LIST_CACHE_TTL_SECONDS", "3600")) +MODEL_LIST_CACHE_TTL_SECONDS = _env_int("MODEL_LIST_CACHE_TTL_SECONDS", 3600) # Shorter TTL applied when the live fetch fails so a transient blip doesn't # suppress live discovery for a full hour. -MODEL_LIST_ERROR_TTL_SECONDS = int(os.getenv("MODEL_LIST_ERROR_TTL_SECONDS", "60")) -MODEL_LIST_REQUEST_TIMEOUT_SECONDS = float(os.getenv("MODEL_LIST_REQUEST_TIMEOUT_SECONDS", "5")) +MODEL_LIST_ERROR_TTL_SECONDS = _env_int("MODEL_LIST_ERROR_TTL_SECONDS", 60) +MODEL_LIST_REQUEST_TIMEOUT_SECONDS = _env_float("MODEL_LIST_REQUEST_TIMEOUT_SECONDS", 5.0) # System Prompt Types SYSTEM_PROMPT_TYPE_TEXT = "text" diff --git a/src/gemini_cli.py b/src/gemini_cli.py new file mode 100644 index 0000000..6a8bcbf --- /dev/null +++ b/src/gemini_cli.py @@ -0,0 +1,214 @@ +import os +import asyncio +import tempfile +import atexit +import shutil +import json +import logging +from typing import AsyncGenerator, Dict, Any, Optional, List +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class GeminiCodeCLI: + def __init__(self, timeout: int = 600000, cwd: Optional[str] = None): + self.timeout = timeout / 1000 # Convert ms to seconds + self.temp_dir = None + self.gemini_cli_path = os.getenv("GEMINI_CLI_PATH", "gemini") + + # If cwd is provided, use it + if cwd: + self.cwd = Path(cwd) + if not self.cwd.exists(): + logger.error(f"ERROR: Specified working directory does not exist: {self.cwd}") + raise ValueError(f"Working directory does not exist: {self.cwd}") + else: + # Create isolated temp directory + self.temp_dir = tempfile.mkdtemp(prefix="gemini_code_workspace_") + self.cwd = Path(self.temp_dir) + logger.info(f"Using temporary isolated workspace: {self.cwd}") + atexit.register(self._cleanup_temp_dir) + + # Gemini API Key from environment + self.gemini_api_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") + + async def verify_cli(self, prompt: str = "Hello") -> bool: + """Verify Gemini CLI is working and authenticated by running a test query.""" + try: + logger.info(f"Testing Gemini CLI with a prewarm query: '{prompt}'...") + + # Use the provided prompt to warm up the CLI and its caches + # We use stream-json to verify the full parsing pipeline + found_response = False + async for event in self.run_completion(prompt, stream=True): + if event.get("type") in ["message", "result"]: + found_response = True + # We can stop as soon as we get the first message piece + break + + if found_response: + logger.info("βœ… Gemini CLI verified and prewarmed successfully") + return True + else: + logger.warning("⚠️ Gemini CLI verification returned no message content") + return False + except Exception as e: + logger.error(f"Gemini CLI verification/prewarm failed: {e}") + logger.warning("Please ensure Gemini CLI is installed: npm install -g @google/gemini-cli") + return False + + async def run_completion( + self, + prompt: str, + system_prompt: Optional[str] = None, + stream: bool = True, + session_id: Optional[str] = None, + continue_session: bool = False, + gemini_options: Optional[Dict] = None, + ) -> AsyncGenerator[Dict[str, Any], None]: + """Run Gemini Agent using the CLI and yield response chunks.""" + + # Build command + cmd = [self.gemini_cli_path, "--output-format", "stream-json"] + + # Add model if specified + if gemini_options and gemini_options.get("model"): + cmd.extend(["--model", gemini_options["model"]]) + + # Handle session continuity + if continue_session and session_id: + cmd.extend(["--resume", session_id]) + elif session_id: + # Try to resume by session ID if it looks like one + cmd.extend(["--resume", session_id]) + + # Add prompt + cmd.extend(["--prompt", prompt]) + + # Add system prompt as a separate instruction if supported or prepend to prompt + if system_prompt: + # Most CLIs don't have a direct flag for system prompt, + # so we prepend it to the prompt if needed, but for agentic CLI + # we might just pass it as part of the context or use a flag if available. + # For Gemini CLI, we can use a custom prompt file or just prepend. + prompt = f"{system_prompt}\n\n{prompt}" + # Update the last element (prompt) + cmd[-1] = prompt + + logger.debug(f"Running Gemini CLI command: {' '.join(cmd)}") + + # Set up environment + env = dict(os.environ) + if self.gemini_api_key: + env["GEMINI_API_KEY"] = self.gemini_api_key + env["GOOGLE_API_KEY"] = self.gemini_api_key + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=self.cwd, + env=env, + ) + + # Read stdout line by line (NDJSON) + while True: + line = await process.stdout.readline() + if not line: + break + + line_str = line.decode().strip() + if not line_str: + continue + + try: + event = json.loads(line_str) + yield event + except json.JSONDecodeError: + logger.warning(f"Failed to parse Gemini CLI output: {line_str}") + + await process.wait() + if process.returncode != 0: + stderr = await process.stderr.read() + error_msg = stderr.decode().strip() + logger.error(f"Gemini CLI exited with error code {process.returncode}: {error_msg}") + yield { + "type": "error", + "subtype": "execution_failed", + "error_message": error_msg or f"Exit code {process.returncode}", + } + + except Exception as e: + logger.error(f"Gemini CLI execution error: {e}") + yield { + "type": "error", + "subtype": "exception", + "error_message": str(e), + } + + def parse_message(self, messages: List[Dict[str, Any]]) -> Optional[str]: + """Extract assistant text from Gemini CLI events.""" + text_parts = [] + for msg in messages: + if msg.get("type") == "message" and "content" in msg: + text_parts.append(msg["content"]) + elif msg.get("type") == "result" and "content" in msg: + # Some versions might put final result in result event + text_parts.append(msg["content"]) + + return "".join(text_parts) if text_parts else None + + def extract_metadata(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]: + """Extract metadata from Gemini CLI events.""" + metadata = { + "session_id": None, + "total_cost_usd": 0.0, + "duration_ms": 0, + "num_turns": 0, + "model": None, + "usage": None, + "stop_reason": None, + } + + for msg in messages: + if msg.get("type") == "init": + metadata["session_id"] = msg.get("session_id") + metadata["model"] = msg.get("model") + elif msg.get("type") == "result": + metadata.update({ + "session_id": msg.get("session_id", metadata["session_id"]), + "usage": msg.get("usage"), + "duration_ms": msg.get("duration_ms", 0), + "total_cost_usd": msg.get("total_cost_usd", 0.0), + "stop_reason": msg.get("stop_reason"), + }) + + return metadata + + def map_stop_reason_openai(self, stop_reason: Optional[str]) -> str: + """Map Gemini stop_reason to OpenAI finish_reason.""" + if stop_reason == "MAX_TOKENS": + return "length" + return "stop" + + def estimate_token_usage( + self, prompt: str, completion: str, model: Optional[str] = None + ) -> Dict[str, int]: + """Estimate token usage.""" + prompt_tokens = max(1, len(prompt) // 4) + completion_tokens = max(1, len(completion) // 4) + return { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + } + + def _cleanup_temp_dir(self): + """Clean up temporary directory.""" + if self.temp_dir and os.path.exists(self.temp_dir): + try: + shutil.rmtree(self.temp_dir) + except Exception: + pass diff --git a/src/main.py b/src/main.py index f03bbb5..38ad066 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,6 @@ import os import json +import re import asyncio import logging import secrets @@ -40,8 +41,15 @@ AnthropicMessagesResponse, AnthropicTextBlock, AnthropicUsage, + AnthropicMessageStartEvent, + AnthropicContentBlockStartEvent, + AnthropicContentBlockDeltaEvent, + AnthropicContentBlockStopEvent, + AnthropicMessageDeltaEvent, + AnthropicMessageStopEvent, ) from src.claude_cli import ClaudeCodeCLI +from src.gemini_cli import GeminiCodeCLI from src.message_adapter import MessageAdapter from src.auth import verify_api_key, security, validate_claude_code_auth, get_claude_code_auth_info from src.parameter_validator import ParameterValidator, CompatibilityReporter @@ -56,16 +64,20 @@ from datetime import datetime, timezone from src import constants +from src import __version__ from src.constants import ( ANTHROPIC_MODELS_URL, ANTHROPIC_VERSION, CLAUDE_MODELS, CLAUDE_TOOLS, DEFAULT_ALLOWED_TOOLS, + DEFAULT_MODEL, DEFAULT_MODEL_FALLBACK, + GLM_MODELS, MODEL_LIST_CACHE_TTL_SECONDS, MODEL_LIST_ERROR_TTL_SECONDS, MODEL_LIST_REQUEST_TIMEOUT_SECONDS, + PASSTHROUGH_MODELS, ) # Load environment variables @@ -98,7 +110,10 @@ def _iso_to_unix(value: Any) -> Optional[int]: return None try: return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()) - except ValueError: + except (ValueError, OverflowError, OSError): + # ValueError: malformed input; OverflowError/OSError: out of platform + # time_t range (32-bit / Windows). Returning None keeps the live fetch + # from discarding the whole page on one bad date. return None @@ -152,10 +167,15 @@ async def _fetch_anthropic_models() -> Optional[List[Dict[str, Any]]]: params: Dict[str, Any] = {"limit": 1000} models: List[Dict[str, Any]] = [] + # Upper bound on pagination so a misbehaving upstream (proxy/gateway that + # keeps reporting has_more=true with a stagnant last_id) cannot hold the + # cache lock indefinitely and hang startup or /v1/models. 1000/page * 100 + # = 100k model cap, far beyond any real catalog. + max_pages = 100 try: async with httpx.AsyncClient(timeout=MODEL_LIST_REQUEST_TIMEOUT_SECONDS) as client: - while True: + for _page in range(max_pages): response = await client.get(ANTHROPIC_MODELS_URL, headers=headers, params=params) response.raise_for_status() payload = response.json() @@ -168,6 +188,10 @@ async def _fetch_anthropic_models() -> Optional[List[Dict[str, Any]]]: if not payload.get("has_more") or not payload.get("last_id"): break params["after_id"] = payload["last_id"] + else: + logger.warning( + "Anthropic models pagination hit the %d-page cap; truncating", max_pages + ) except Exception as exc: # noqa: BLE001 - endpoint should degrade gracefully logger.warning("Failed to fetch Anthropic model list, using fallback: %s", exc) return None @@ -209,16 +233,44 @@ async def get_available_models() -> List[Dict[str, Any]]: return fallback_models +def _append_passthrough(models: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Append non-Anthropic passthrough models (GLM, Gemini) to a model list. + + These models are served through Claude Code or the Gemini proxy and are + never returned by Anthropic's live Models API, so they are merged in here + at the /v1/models edge. Already-present ids are not duplicated. + """ + existing_ids = {m.get("id") for m in models} + augmented = list(models) + for model_id in PASSTHROUGH_MODELS: + if model_id not in existing_ids: + augmented.append( + {"id": model_id, "object": "model", "created": 0, "owned_by": "passthrough"} + ) + return augmented + + +def _version_key(model_id: str): + """Tuple of integers embedded in the id, for ordering version-suffixed names. + + Lexicographic id comparison mis-orders multi-digit versions + (e.g. "claude-sonnet-4-9" > "claude-sonnet-4-10"); this key treats the + numeric runs as integers so 4-10 ranks above 4-9. + """ + return tuple(int(x) for x in re.findall(r"\d+", model_id)) + + def _pick_latest_sonnet(models: List[Dict[str, Any]]) -> Optional[str]: """Return the id of the newest Sonnet model in `models`, or None.""" sonnets = [m for m in models if isinstance(m.get("id"), str) and "sonnet" in m["id"].lower()] if not sonnets: return None # Prefer Anthropic-provided created_at; fall back to the int `created` we set, - # then to id-sort (date-suffixed ids sort correctly newest-last). + # then to a numeric version-key (so 4-10 > 4-9), then the raw id. sonnets.sort( key=lambda m: ( _iso_to_unix(m.get("created_at")) or m.get("created") or 0, + _version_key(m["id"]), m["id"], ) ) @@ -320,10 +372,26 @@ def prompt_for_api_protection() -> Optional[str]: timeout=int(os.getenv("MAX_TIMEOUT", "600000")), cwd=os.getenv("CLAUDE_CWD") ) +# Initialize Gemini CLI +gemini_cli = GeminiCodeCLI( + timeout=int(os.getenv("MAX_TIMEOUT", "600000")), cwd=os.getenv("CLAUDE_CWD") +) + +# Global semaphore for limiting concurrent CLI processes +# Default to 3 concurrent processes to avoid resource exhaustion +MAX_CONCURRENT_PROCESSES = int(os.getenv("MAX_CONCURRENT_PROCESSES", "3")) +process_semaphore = None + @asynccontextmanager async def lifespan(app: FastAPI): """Verify Claude Code authentication and CLI on startup.""" + global process_semaphore + + # Initialize the semaphore within the event loop + process_semaphore = asyncio.Semaphore(MAX_CONCURRENT_PROCESSES) + logger.info(f"Initialized process concurrency cap: {MAX_CONCURRENT_PROCESSES}") + logger.info("Verifying Claude Code authentication and CLI...") # Validate authentication first @@ -340,25 +408,49 @@ async def lifespan(app: FastAPI): else: logger.info(f"βœ… Claude Code authentication validated: {auth_info['method']}") - # Verify Claude Agent SDK with timeout for graceful degradation + # Verify both CLI backends in parallel to reduce startup latency + # and ensure they are both prewarmed for the first request + tasks = [] + + # Prewarm prompt can be customized via environment variable + prewarm_prompt = os.getenv("PREWARM_PROMPT", "Hello") + + # Task for Claude Agent SDK + logger.info(f"Prewarming Claude Agent SDK with prompt: '{prewarm_prompt}'...") + tasks.append(asyncio.wait_for(claude_cli.verify_cli(prompt=prewarm_prompt), timeout=45.0)) + + # Task for Gemini CLI if configured + is_gemini_configured = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_CLI_PATH") == "gemini" + if is_gemini_configured: + logger.info(f"Prewarming Gemini CLI with prompt: '{prewarm_prompt}'...") + tasks.append(asyncio.wait_for(gemini_cli.verify_cli(prompt=prewarm_prompt), timeout=45.0)) + try: - logger.info("Testing Claude Agent SDK connection...") - # Use asyncio.wait_for to enforce timeout (30 seconds) - cli_verified = await asyncio.wait_for(claude_cli.verify_cli(), timeout=30.0) - - if cli_verified: - logger.info("βœ… Claude Agent SDK verified successfully") + # Run both prewarm queries in parallel + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Check Claude result (always index 0) + claude_result = results[0] + if isinstance(claude_result, Exception): + logger.error(f"⚠️ Claude prewarm failed: {claude_result}") + elif not claude_result: + logger.warning("⚠️ Claude prewarm returned False") else: - logger.warning("⚠️ Claude Agent SDK verification returned False") - logger.warning("The server will start, but requests may fail.") - except asyncio.TimeoutError: - logger.warning("⚠️ Claude Agent SDK verification timed out (30s)") - logger.warning("This may indicate network issues or SDK configuration problems.") - logger.warning("The server will start, but first request may be slow.") + logger.info("βœ… Claude prewarm complete") + + # Check Gemini result if it was requested (index 1) + if is_gemini_configured and len(results) > 1: + gemini_result = results[1] + if isinstance(gemini_result, Exception): + logger.error(f"⚠️ Gemini prewarm failed: {gemini_result}") + elif not gemini_result: + logger.warning("⚠️ Gemini prewarm returned False") + else: + logger.info("βœ… Gemini prewarm complete") + except Exception as e: - logger.error(f"⚠️ Claude Agent SDK verification failed: {e}") - logger.warning("The server will start, but requests may fail.") - logger.warning("Check that Claude Code CLI is properly installed and authenticated.") + logger.error(f"⚠️ Error during parallel prewarming: {e}") + logger.warning("The server will start, but first requests might be slow.") # Log debug information if debug mode is enabled if DEBUG_MODE or VERBOSE: @@ -396,14 +488,14 @@ async def lifespan(app: FastAPI): # Cleanup on shutdown logger.info("Shutting down session manager...") - session_manager.shutdown() + await session_manager.shutdown() # Create FastAPI app app = FastAPI( title="Claude Code OpenAI API Wrapper", description="OpenAI-compatible API for Claude Code", - version="1.0.0", + version=__version__, lifespan=lifespan, ) @@ -499,7 +591,7 @@ async def dispatch(self, request: Request, call_next): f"πŸ” Request body: {json_lib.dumps(parsed_body, indent=2)}" ) body_logged = True - except: + except Exception: logger.debug(f"πŸ” Request body (raw): {body.decode()[:500]}...") body_logged = True except Exception as e: @@ -561,7 +653,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE body = await request.body() if body: debug_info["raw_request_body"] = body.decode() - except: + except Exception: debug_info["raw_request_body"] = "Could not read request body" error_response = { @@ -589,18 +681,44 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE return JSONResponse(status_code=422, content=error_response) +def get_cli_for_model(model_name: Optional[str]): + """Determine which CLI to use based on the model name.""" + if model_name and ( + model_name.startswith("gemini") + or model_name in ["pro", "flash", "flash-lite", "auto"] + ): + return gemini_cli + return claude_cli + + +def get_prompt_messages(all_messages: List[Message], is_resuming: bool) -> List[Message]: + """ + Get the messages to send as the prompt. + + Wrapper-managed `session_id` values are not native Claude/Gemini resume tokens, + so session continuity is preserved by replaying the full conversation history. + """ + return all_messages + + async def generate_streaming_response( request: ChatCompletionRequest, request_id: str, claude_headers: Optional[Dict[str, Any]] = None ) -> AsyncGenerator[str, None]: """Generate SSE formatted streaming response.""" try: + # Determine which CLI to use + active_cli = get_cli_for_model(request.model) + # Process messages with session management - all_messages, actual_session_id = session_manager.process_messages( + all_messages, actual_session_id = await session_manager.process_messages( request.messages, request.session_id ) + + # Only send last message if we are resuming an existing session + prompt_messages = get_prompt_messages(all_messages, bool(actual_session_id)) - # Convert messages to prompt - prompt, system_prompt = MessageAdapter.messages_to_prompt(all_messages) + # Convert messages to prompt (pass model for optimized formatting) + prompt, system_prompt = MessageAdapter.messages_to_prompt(prompt_messages, request.model) # Add sampling instructions from temperature/top_p if present sampling_instructions = request.get_sampling_instructions() @@ -611,106 +729,192 @@ async def generate_streaming_response( system_prompt = sampling_instructions logger.debug(f"Added sampling instructions: {sampling_instructions}") - # Filter content for unsupported features - prompt = MessageAdapter.filter_content(prompt) - if system_prompt: - system_prompt = MessageAdapter.filter_content(system_prompt) - - # Get Claude Agent SDK options from request - claude_options = request.to_claude_options() + # Get options from request + options = request.to_claude_options() - # Merge with Claude-specific headers if provided + # Merge with specific headers if provided if claude_headers: - claude_options.update(claude_headers) + options.update(claude_headers) - # Validate model - if claude_options.get("model"): - ParameterValidator.validate_model(claude_options["model"]) + # Validate model (only for Claude) + if active_cli == claude_cli and options.get("model"): + ParameterValidator.validate_model(options["model"]) - # Handle tools - disabled by default for OpenAI compatibility + # Handle tools 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 + # Disable all tools + if active_cli == claude_cli: + options["disallowed_tools"] = CLAUDE_TOOLS + options["tools"] = [] # empty base set: no tool schemas sent to the model + options["setting_sources"] = [] # skip CLAUDE.md / memory injection + # Leave max_turns at the SDK default: with all tools disallowed + # there is nothing to execute, and max_turns=1 turns a stray + # passthrough tool_use into a hard "maximum turns" error. 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" + # Enable tools + if active_cli == claude_cli: + options["allowed_tools"] = DEFAULT_ALLOWED_TOOLS + # Set permission mode to bypass prompts (required for API/headless usage) + options["permission_mode"] = "bypassPermissions" logger.info(f"Tools enabled by user request: {DEFAULT_ALLOWED_TOOLS}") - # Run Claude Code + # Run CLI chunks_buffer = [] role_sent = False # Track if we've sent the initial role chunk content_sent = False # Track if we've sent any content + + # Buffering for echo detection + streaming_content_buffer = "" + prompt_stripped = False + is_gemini = active_cli == gemini_cli + + # Call the appropriate CLI within the process semaphore to limit concurrency + async with (process_semaphore or asyncio.Semaphore(MAX_CONCURRENT_PROCESSES)): + if active_cli == gemini_cli: + completion_gen = gemini_cli.run_completion( + prompt=prompt, + system_prompt=system_prompt, + stream=True, + session_id=None, + gemini_options=options, + ) + else: + completion_gen = claude_cli.run_completion( + prompt=prompt, + system_prompt=system_prompt, + stream=True, + session_id=None, + claude_options=options, + ) - 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, - ): - chunks_buffer.append(chunk) - - # Check if we have an assistant message - # Handle both old format (type/message structure) and new format (direct content) - content = None - if chunk.get("type") == "assistant" and "message" in chunk: - # Old format: {"type": "assistant", "message": {"content": [...]}} - message = chunk["message"] - if isinstance(message, dict) and "content" in message: - content = message["content"] - elif "content" in chunk and isinstance(chunk["content"], list): - # New format: {"content": [TextBlock(...)]} (converted AssistantMessage) - content = chunk["content"] - - if content is not None: - # Send initial role chunk if we haven't already - 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 - - # Handle content blocks - if isinstance(content, list): - for block in content: - # Handle TextBlock objects from Claude Agent SDK - if hasattr(block, "text"): - raw_text = block.text - # Handle dictionary format for backward compatibility - elif isinstance(block, dict) and block.get("type") == "text": - raw_text = block.get("text", "") - else: - continue + async for chunk in completion_gen: + chunks_buffer.append(chunk) + + if DEBUG_MODE or VERBOSE: + logger.debug(f"Streaming chunk: type={chunk.get('type')}, subtype={chunk.get('subtype')}, keys={list(chunk.keys())}") + + # Check if we have an assistant message + # Handle both Claude and Gemini formats + content = None + if (chunk.get("type") == "assistant" or chunk.get("type") == "assistant_message") and "message" in chunk: + # Claude format: {"type": "assistant", "message": {"content": [...]}} + message = chunk["message"] + if isinstance(message, dict) and "content" in message: + content = message["content"] + elif chunk.get("type") == "content_block_delta" and "delta" in chunk: + # Claude SDK delta format: {"type": "content_block_delta", "delta": {"text": "..."}} + delta = chunk["delta"] + if isinstance(delta, dict) and "text" in delta: + content = delta["text"] + elif "content" in chunk and isinstance(chunk["content"], list): + # Claude SDK format: {"content": [TextBlock(...)]} + content = chunk["content"] + elif chunk.get("type") == "message" and "content" in chunk: + # Gemini format: {"type": "message", "content": "..."} + content = chunk["content"] + elif chunk.get("type") == "result" and "content" in chunk: + # Gemini final result format + content = chunk["content"] + + if content is not None: + # Send initial role chunk if we haven't already + 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 + + # Handle content blocks + if isinstance(content, list): + for block in content: + # Handle TextBlock objects from Claude Agent SDK + if hasattr(block, "text"): + raw_text = block.text + # Handle dictionary format for backward compatibility + elif isinstance(block, dict) and block.get("type") == "text": + raw_text = block.get("text", "") + else: + continue + + if DEBUG_MODE or VERBOSE: + logger.debug(f"Raw content block: {raw_text[:200]}...") + + # Filter out tool usage and thinking blocks + filtered_text = MessageAdapter.filter_content(raw_text) + + if filtered_text and not filtered_text.isspace(): + # Echo stripping logic for Gemini + if is_gemini and not prompt_stripped: + streaming_content_buffer += filtered_text + if len(streaming_content_buffer) > len(prompt) + 20: + # We have enough to check for echo + if streaming_content_buffer.startswith(prompt): + filtered_text = streaming_content_buffer[len(prompt):].lstrip() + # Also handle potential Assistant: prefix + if filtered_text.startswith("Assistant:"): + filtered_text = filtered_text[len("Assistant:"):].lstrip() + else: + filtered_text = streaming_content_buffer + prompt_stripped = True + else: + # Keep buffering + continue + + # 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): + if DEBUG_MODE or VERBOSE: + logger.debug(f"Raw content string: {content[:200]}...") + # Filter out tool usage and thinking blocks - filtered_text = MessageAdapter.filter_content(raw_text) + filtered_content = MessageAdapter.filter_content(content) + + if filtered_content and not filtered_content.isspace(): + # Echo stripping logic for Gemini + if is_gemini and not prompt_stripped: + streaming_content_buffer += filtered_content + if len(streaming_content_buffer) > len(prompt) + 20: + if streaming_content_buffer.startswith(prompt): + filtered_content = streaming_content_buffer[len(prompt):].lstrip() + if filtered_content.startswith("Assistant:"): + filtered_content = filtered_content[len("Assistant:"):].lstrip() + else: + filtered_content = streaming_content_buffer + prompt_stripped = True + else: + continue - if filtered_text and not filtered_text.isspace(): # 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 ) ], ) @@ -718,25 +922,22 @@ 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 - ) - ], - ) - - yield f"data: {stream_chunk.model_dump_json()}\n\n" - content_sent = True - + # Handle buffered content if prompt_stripped was never set to True + if is_gemini and not prompt_stripped and streaming_content_buffer: + final_content = streaming_content_buffer + if final_content.startswith(prompt): + final_content = final_content[len(prompt):].lstrip() + if final_content.startswith("Assistant:"): + final_content = final_content[len("Assistant:"):].lstrip() + + if final_content: + stream_chunk = ChatCompletionStreamResponse( + id=request_id, + model=request.model, + choices=[StreamChoice(index=0, delta={"content": final_content}, finish_reason=None)], + ) + yield f"data: {stream_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: # Send role chunk with empty content if we never got any assistant messages @@ -754,6 +955,7 @@ async def generate_streaming_response( # If we sent role but no content, send a minimal response if role_sent and not content_sent: + logger.warning(f"No content generated for request {request_id} (role_sent={role_sent})") fallback_chunk = ChatCompletionStreamResponse( id=request_id, model=request.model, @@ -770,31 +972,46 @@ async def generate_streaming_response( # Extract assistant response from all chunks assistant_content = None if chunks_buffer: - assistant_content = claude_cli.parse_claude_message(chunks_buffer) + assistant_content = active_cli.parse_message(chunks_buffer) if active_cli == gemini_cli else active_cli.parse_claude_message(chunks_buffer) # Store in session if applicable if actual_session_id and assistant_content: assistant_message = Message(role="assistant", content=assistant_content) - session_manager.add_assistant_response(actual_session_id, assistant_message) + await session_manager.add_assistant_response(actual_session_id, assistant_message) + + # Extract real metadata (usage + stop_reason) from SDK messages + metadata = active_cli.extract_metadata(chunks_buffer) # Prepare usage data if requested usage_data = None if request.stream_options and request.stream_options.include_usage: - # Estimate token usage based on prompt and completion - completion_text = assistant_content or "" - token_usage = claude_cli.estimate_token_usage(prompt, completion_text, request.model) - usage_data = Usage( - prompt_tokens=token_usage["prompt_tokens"], - completion_tokens=token_usage["completion_tokens"], - total_tokens=token_usage["total_tokens"], - ) - logger.debug(f"Estimated usage: {usage_data}") + sdk_usage = metadata.get("usage") + if sdk_usage and isinstance(sdk_usage, dict): + # Handle both Anthropic and Gemini usage formats + pt = sdk_usage.get("input_tokens", sdk_usage.get("prompt_tokens", 0)) + ct = sdk_usage.get("output_tokens", sdk_usage.get("completion_tokens", 0)) + usage_data = Usage( + prompt_tokens=pt, + completion_tokens=ct, + total_tokens=pt + ct, + ) + else: + # Fall back to estimate + completion_text = assistant_content or "" + token_usage = active_cli.estimate_token_usage(prompt, completion_text, request.model) + usage_data = Usage( + prompt_tokens=token_usage["prompt_tokens"], + completion_tokens=token_usage["completion_tokens"], + total_tokens=token_usage["total_tokens"], + ) + logger.debug(f"Usage: {usage_data}") - # Send final chunk with finish reason and optionally usage data + # Send final chunk with mapped finish_reason and optionally usage data + finish_reason = active_cli.map_stop_reason_openai(metadata.get("stop_reason")) final_chunk = ChatCompletionStreamResponse( id=request_id, model=request.model, - choices=[StreamChoice(index=0, delta={}, finish_reason="stop")], + choices=[StreamChoice(index=0, delta={}, finish_reason=finish_reason)], # type: ignore[arg-type] usage=usage_data, ) yield f"data: {final_chunk.model_dump_json()}\n\n" @@ -806,6 +1023,213 @@ async def generate_streaming_response( yield f"data: {json.dumps(error_chunk)}\n\n" +async def generate_anthropic_streaming_response( + request: AnthropicMessagesRequest, + request_id: str, + claude_headers: Optional[Dict[str, Any]] = None, +) -> AsyncGenerator[str, None]: + """Generate Anthropic SSE formatted streaming response.""" + try: + # Convert messages and prepend system message + messages = request.to_openai_messages() + if request.system: + messages = [Message(role="system", content=request.system)] + messages + + # Process messages with session management + all_messages, actual_session_id = await session_manager.process_messages( + messages, request.session_id + ) + + # Only send new messages if we are resuming an existing session + prompt_messages = get_prompt_messages(all_messages, bool(actual_session_id)) + + # Convert messages to prompt (pass model for optimized formatting) + prompt, system_prompt = MessageAdapter.messages_to_prompt(prompt_messages, request.model) + + # Add sampling instructions + sampling_instructions = request.get_sampling_instructions() + if sampling_instructions: + if system_prompt: + system_prompt = f"{system_prompt}\n\n{sampling_instructions}" + else: + system_prompt = sampling_instructions + + # Build options + options: Dict[str, Any] = {"model": request.model} + if claude_headers: + options.update(claude_headers) + + # Determine which CLI to use + active_cli = get_cli_for_model(request.model) + + # Validate model (only for Claude) + if active_cli == claude_cli and options.get("model"): + ParameterValidator.validate_model(options["model"]) + + # Configure tools + if not request.enable_tools: + if active_cli == claude_cli: + options["disallowed_tools"] = CLAUDE_TOOLS + options["tools"] = [] # empty base set: no tool schemas sent to the model + options["setting_sources"] = [] # skip CLAUDE.md / memory injection + # Leave max_turns at the SDK default: with all tools disallowed + # there is nothing to execute, and max_turns=1 turns a stray + # passthrough tool_use into a hard "maximum turns" error. + else: + if active_cli == claude_cli: + options["allowed_tools"] = DEFAULT_ALLOWED_TOOLS + options["permission_mode"] = "bypassPermissions" + + # Emit message_start + start_event = AnthropicMessageStartEvent( + message={ + "id": request_id, + "type": "message", + "role": "assistant", + "content": [], + "model": request.model, + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 0, "output_tokens": 0}, + } + ) + yield f"event: message_start\ndata: {start_event.model_dump_json()}\n\n" + + # Emit content_block_start + block_start = AnthropicContentBlockStartEvent( + index=0, content_block={"type": "text", "text": ""} + ) + yield f"event: content_block_start\ndata: {block_start.model_dump_json()}\n\n" + + chunks_buffer = [] + content_sent = False + + # Call the appropriate CLI within the process semaphore to limit concurrency + async with (process_semaphore or asyncio.Semaphore(MAX_CONCURRENT_PROCESSES)): + if active_cli == gemini_cli: + completion_gen = gemini_cli.run_completion( + prompt=prompt, + system_prompt=system_prompt, + stream=True, + session_id=None, + gemini_options=options, + ) + else: + completion_gen = claude_cli.run_completion( + prompt=prompt, + system_prompt=system_prompt, + stream=True, + session_id=None, + claude_options=options, + ) + + async for chunk in completion_gen: + chunks_buffer.append(chunk) + + if DEBUG_MODE or VERBOSE: + logger.debug(f"Anthropic streaming chunk: type={chunk.get('type')}, subtype={chunk.get('subtype')}, keys={list(chunk.keys())}") + + content = None + if (chunk.get("type") == "assistant" or chunk.get("type") == "assistant_message") and "message" in chunk: + message = chunk["message"] + if isinstance(message, dict) and "content" in message: + content = message["content"] + elif chunk.get("type") == "content_block_delta" and "delta" in chunk: + # Claude SDK delta format: {"type": "content_block_delta", "delta": {"text": "..."}} + delta = chunk["delta"] + if isinstance(delta, dict) and "text" in delta: + content = delta["text"] + elif "content" in chunk and isinstance(chunk["content"], list): + content = chunk["content"] + elif chunk.get("type") == "message" and "content" in chunk: + content = chunk["content"] + elif chunk.get("type") == "result" and "content" in chunk: + content = chunk["content"] + + if content is not None: + if isinstance(content, list): + for block in content: + if hasattr(block, "text"): + raw_text = block.text + elif isinstance(block, dict) and block.get("type") == "text": + raw_text = block.get("text", "") + else: + continue + + if DEBUG_MODE or VERBOSE: + logger.debug(f"Raw anthropic content block: {raw_text[:200]}...") + + filtered_text = MessageAdapter.filter_content(raw_text) + if filtered_text and not filtered_text.isspace(): + delta_event = AnthropicContentBlockDeltaEvent( + index=0, + delta={"type": "text_delta", "text": filtered_text}, + ) + yield f"event: content_block_delta\ndata: {delta_event.model_dump_json()}\n\n" + content_sent = True + + elif isinstance(content, str): + if DEBUG_MODE or VERBOSE: + logger.debug(f"Raw anthropic content string: {content[:200]}...") + + filtered_content = MessageAdapter.filter_content(content) + if filtered_content and not filtered_content.isspace(): + delta_event = AnthropicContentBlockDeltaEvent( + index=0, + delta={"type": "text_delta", "text": filtered_content}, + ) + yield f"event: content_block_delta\ndata: {delta_event.model_dump_json()}\n\n" + content_sent = True + + # If no content was sent, send a minimal response + if not content_sent: + delta_event = AnthropicContentBlockDeltaEvent( + index=0, + delta={"type": "text_delta", "text": "I'm unable to provide a response at the moment."}, + ) + yield f"event: content_block_delta\ndata: {delta_event.model_dump_json()}\n\n" + + # Emit content_block_stop + block_stop = AnthropicContentBlockStopEvent(index=0) + yield f"event: content_block_stop\ndata: {block_stop.model_dump_json()}\n\n" + + # Extract and store assistant content + assistant_content = None + if chunks_buffer: + assistant_content = active_cli.parse_message(chunks_buffer) if active_cli == gemini_cli else active_cli.parse_claude_message(chunks_buffer) + if actual_session_id and assistant_content: + assistant_message = Message(role="assistant", content=assistant_content) + await session_manager.add_assistant_response(actual_session_id, assistant_message) + + # Use real token counts from SDK metadata when available + metadata = active_cli.extract_metadata(chunks_buffer) + sdk_usage = metadata.get("usage") + if sdk_usage and isinstance(sdk_usage, dict): + output_tokens = sdk_usage.get("output_tokens", sdk_usage.get("completion_tokens", 0)) + else: + completion_text = assistant_content or "" + output_tokens = MessageAdapter.estimate_tokens(completion_text) + + # Real stop_reason from SDK (Anthropic format: "end_turn", "max_tokens", etc.) + stop_reason = metadata.get("stop_reason") or "end_turn" + + # Emit message_delta + msg_delta = AnthropicMessageDeltaEvent( + delta={"type": "message_delta", "stop_reason": stop_reason, "stop_sequence": None}, + usage={"output_tokens": output_tokens}, + ) + yield f"event: message_delta\ndata: {msg_delta.model_dump_json()}\n\n" + + # Emit message_stop + msg_stop = AnthropicMessageStopEvent() + yield f"event: message_stop\ndata: {msg_stop.model_dump_json()}\n\n" + + except Exception as e: + logger.error(f"Anthropic streaming error: {e}") + error_chunk = {"error": {"message": str(e), "type": "streaming_error"}} + yield f"data: {json.dumps(error_chunk)}\n\n" + + @app.post("/v1/chat/completions") @rate_limit_endpoint("chat") async def chat_completions( @@ -853,16 +1277,19 @@ async def chat_completions( else: # Non-streaming response # Process messages with session management - all_messages, actual_session_id = session_manager.process_messages( + all_messages, actual_session_id = await session_manager.process_messages( request_body.messages, request_body.session_id ) + # Only send new messages if we are resuming an existing session + prompt_messages = get_prompt_messages(all_messages, bool(actual_session_id)) + logger.info( - f"Chat completion: session_id={actual_session_id}, total_messages={len(all_messages)}" + f"Chat completion: session_id={actual_session_id}, total_messages={len(all_messages)}, prompt_messages={len(prompt_messages)}" ) - # Convert messages to prompt - prompt, system_prompt = MessageAdapter.messages_to_prompt(all_messages) + # Convert messages to prompt (pass model for optimized formatting) + prompt, system_prompt = MessageAdapter.messages_to_prompt(prompt_messages, request_body.model) # Add sampling instructions from temperature/top_p if present sampling_instructions = request_body.get_sampling_instructions() @@ -878,61 +1305,92 @@ 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() + # Determine which CLI to use + active_cli = get_cli_for_model(request_body.model) - # Merge with Claude-specific headers + # Get options from request + options = request_body.to_claude_options() + + # Merge with headers if claude_headers: - claude_options.update(claude_headers) + options.update(claude_headers) - # Validate model - if claude_options.get("model"): - ParameterValidator.validate_model(claude_options["model"]) + # Validate model (only for Claude) + if active_cli == claude_cli and options.get("model"): + ParameterValidator.validate_model(options["model"]) - # Handle tools - disabled by default for OpenAI compatibility + # Handle tools 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 + # Disable all tools + if active_cli == claude_cli: + options["disallowed_tools"] = CLAUDE_TOOLS + options["tools"] = [] # empty base set: no tool schemas sent to the model + options["setting_sources"] = [] # skip CLAUDE.md / memory injection + # Leave max_turns at the SDK default: with all tools disallowed + # there is nothing to execute, and max_turns=1 turns a stray + # passthrough tool_use into a hard "maximum turns" error. 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" + # Enable tools + if active_cli == claude_cli: + options["allowed_tools"] = DEFAULT_ALLOWED_TOOLS + # Set permission mode to bypass prompts (required for API/headless usage) + options["permission_mode"] = "bypassPermissions" logger.info(f"Tools enabled by user request: {DEFAULT_ALLOWED_TOOLS}") # 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, - ): - chunks.append(chunk) + + # Call the appropriate CLI within the process semaphore to limit concurrency + # We wrap the entire execution generator to ensure the process cap is respected + async with (process_semaphore or asyncio.Semaphore(MAX_CONCURRENT_PROCESSES)): + if active_cli == gemini_cli: + completion_gen = gemini_cli.run_completion( + prompt=prompt, + system_prompt=system_prompt, + stream=False, + session_id=None, + gemini_options=options, + ) + else: + completion_gen = claude_cli.run_completion( + prompt=prompt, + system_prompt=system_prompt, + stream=False, + session_id=None, + claude_options=options, + ) + + async for chunk in completion_gen: + chunks.append(chunk) # Extract assistant message - raw_assistant_content = claude_cli.parse_claude_message(chunks) + raw_assistant_content = active_cli.parse_message(chunks) if active_cli == gemini_cli else active_cli.parse_claude_message(chunks) if not raw_assistant_content: raise HTTPException(status_code=500, detail="No response from Claude Code") - # Filter out tool usage and thinking blocks - assistant_content = MessageAdapter.filter_content(raw_assistant_content) + # Filter out tool usage and thinking blocks, also handle potential echoes + assistant_content = MessageAdapter.filter_content(raw_assistant_content, prompt_echo=prompt) # Add assistant response to session if using session mode if actual_session_id: assistant_message = Message(role="assistant", content=assistant_content) - session_manager.add_assistant_response(actual_session_id, assistant_message) + await session_manager.add_assistant_response(actual_session_id, assistant_message) + + # Use real token counts from SDK metadata when available + metadata = active_cli.extract_metadata(chunks) + sdk_usage = metadata.get("usage") + if sdk_usage and isinstance(sdk_usage, dict): + # Handle both Anthropic and Gemini usage formats + prompt_tokens = sdk_usage.get("input_tokens", sdk_usage.get("prompt_tokens", 0)) + completion_tokens = sdk_usage.get("output_tokens", sdk_usage.get("completion_tokens", 0)) + else: + prompt_tokens = MessageAdapter.estimate_tokens(prompt) + completion_tokens = MessageAdapter.estimate_tokens(assistant_content) - # Estimate tokens (rough approximation) - prompt_tokens = MessageAdapter.estimate_tokens(prompt) - completion_tokens = MessageAdapter.estimate_tokens(assistant_content) + # Map stop_reason to OpenAI finish_reason + finish_reason = active_cli.map_stop_reason_openai(metadata.get("stop_reason")) # Create response response = ChatCompletionResponse( @@ -942,7 +1400,7 @@ async def chat_completions( Choice( index=0, message=Message(role="assistant", content=assistant_content), - finish_reason="stop", + finish_reason=finish_reason, # type: ignore[arg-type] ) ], usage=Usage( @@ -988,68 +1446,138 @@ async def anthropic_messages( } raise HTTPException(status_code=503, detail=error_detail) + print(f"[/v1/messages] Handler entered, model={request_body.model}", flush=True) try: + request_id = f"msg_{os.urandom(12).hex()}" logger.info(f"Anthropic Messages API request: model={request_body.model}") - # Convert Anthropic messages to internal format + # Extract Claude-specific parameters from headers + claude_headers = ParameterValidator.extract_claude_headers(dict(request.headers)) + + if request_body.stream: + return StreamingResponse( + generate_anthropic_streaming_response(request_body, request_id, claude_headers), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + + # Non-streaming: convert messages and prepend system messages = request_body.to_openai_messages() + if request_body.system: + messages = [Message(role="system", content=request_body.system)] + messages + + # Process with session management + all_messages, actual_session_id = await session_manager.process_messages( + messages, request_body.session_id + ) + + # Only send new messages if we are resuming an existing session + prompt_messages = get_prompt_messages(all_messages, bool(actual_session_id)) - # Build prompt from messages - prompt_parts = [] - for msg in messages: - if msg.role == "user": - prompt_parts.append(msg.content) - elif msg.role == "assistant": - prompt_parts.append(f"Assistant: {msg.content}") + # Convert to prompt (pass model for optimized formatting) + prompt, system_prompt = MessageAdapter.messages_to_prompt(prompt_messages, request_body.model) - prompt = "\n\n".join(prompt_parts) - system_prompt = request_body.system + # Add sampling instructions + sampling_instructions = request_body.get_sampling_instructions() + if sampling_instructions: + if system_prompt: + system_prompt = f"{system_prompt}\n\n{sampling_instructions}" + else: + system_prompt = sampling_instructions - # Filter content - prompt = MessageAdapter.filter_content(prompt) - if system_prompt: - system_prompt = MessageAdapter.filter_content(system_prompt) + # Build options + options: Dict[str, Any] = {"model": request_body.model} + if claude_headers: + options.update(claude_headers) + + # Determine which CLI to use + active_cli = get_cli_for_model(request_body.model) + + # Validate model (only for Claude) + if active_cli == claude_cli and options.get("model"): + ParameterValidator.validate_model(options["model"]) + + # Configure tools + if not request_body.enable_tools: + if active_cli == claude_cli: + options["disallowed_tools"] = CLAUDE_TOOLS + options["tools"] = [] # empty base set: no tool schemas sent to the model + options["setting_sources"] = [] # skip CLAUDE.md / memory injection + # Leave max_turns at the SDK default: with all tools disallowed + # there is nothing to execute, and max_turns=1 turns a stray + # passthrough tool_use into a hard "maximum turns" error. + else: + if active_cli == claude_cli: + options["allowed_tools"] = DEFAULT_ALLOWED_TOOLS + options["permission_mode"] = "bypassPermissions" - # Run Claude Code - tools enabled by default for Anthropic SDK clients - # (they're typically using this for agentic workflows) + # Run CLI + print(f"[/v1/messages] Calling run_completion, enable_tools={request_body.enable_tools}", flush=True) chunks = [] - async for chunk in claude_cli.run_completion( - prompt=prompt, - system_prompt=system_prompt, - model=request_body.model, - max_turns=10, - allowed_tools=DEFAULT_ALLOWED_TOOLS, - permission_mode="bypassPermissions", - stream=False, - ): - chunks.append(chunk) + + # Call the appropriate CLI within the process semaphore to limit concurrency + async with (process_semaphore or asyncio.Semaphore(MAX_CONCURRENT_PROCESSES)): + if active_cli == gemini_cli: + completion_gen = gemini_cli.run_completion( + prompt=prompt, + system_prompt=system_prompt, + stream=False, + session_id=None, + gemini_options=options, + ) + else: + completion_gen = claude_cli.run_completion( + prompt=prompt, + system_prompt=system_prompt, + stream=False, + session_id=None, + claude_options=options, + ) + + async for chunk in completion_gen: + chunks.append(chunk) # Extract assistant message - raw_assistant_content = claude_cli.parse_claude_message(chunks) + raw_assistant_content = active_cli.parse_message(chunks) if active_cli == gemini_cli else active_cli.parse_claude_message(chunks) if not raw_assistant_content: - raise HTTPException(status_code=500, detail="No response from Claude Code") - - # Filter out tool usage and thinking blocks - assistant_content = MessageAdapter.filter_content(raw_assistant_content) + raise HTTPException(status_code=500, detail="No response from CLI") + + # Filter out tool usage and thinking blocks, also handle potential echoes + assistant_content = MessageAdapter.filter_content(raw_assistant_content, prompt_echo=prompt) + + # Store in session + if actual_session_id: + assistant_message = Message(role="assistant", content=assistant_content) + await session_manager.add_assistant_response(actual_session_id, assistant_message) + + # Use real token counts from metadata when available + metadata = active_cli.extract_metadata(chunks) + sdk_usage = metadata.get("usage") + if sdk_usage and isinstance(sdk_usage, dict): + # Handle both Anthropic and Gemini usage formats + prompt_tokens = sdk_usage.get("input_tokens", sdk_usage.get("prompt_tokens", 0)) + completion_tokens = sdk_usage.get("output_tokens", sdk_usage.get("completion_tokens", 0)) + else: + prompt_tokens = MessageAdapter.estimate_tokens(prompt) + completion_tokens = MessageAdapter.estimate_tokens(assistant_content) - # Estimate tokens - prompt_tokens = MessageAdapter.estimate_tokens(prompt) - completion_tokens = MessageAdapter.estimate_tokens(assistant_content) + # Real stop_reason from SDK + stop_reason = metadata.get("stop_reason") or "end_turn" - # Create Anthropic-format response - response = AnthropicMessagesResponse( + return AnthropicMessagesResponse( model=request_body.model, content=[AnthropicTextBlock(text=assistant_content)], - stop_reason="end_turn", + stop_reason=stop_reason, # type: ignore[arg-type] usage=AnthropicUsage( input_tokens=prompt_tokens, output_tokens=completion_tokens, ), ) - return response - except HTTPException: raise except Exception as e: @@ -1065,7 +1593,7 @@ async def list_models( # Check FastAPI API key if configured await verify_api_key(request, credentials) - return {"object": "list", "data": await get_available_models()} + return {"object": "list", "data": _append_passthrough(await get_available_models())} @app.post("/v1/compatibility") @@ -1555,7 +2083,7 @@ async def root(): - + @@ -1775,7 +2303,7 @@ async def debug_request_validation(request: Request): "validation_result": validation_result, "debug_mode_enabled": DEBUG_MODE or VERBOSE, "example_valid_request": { - "model": "claude-3-sonnet-20240229", + "model": DEFAULT_MODEL, "messages": [{"role": "user", "content": "Hello, world!"}], "stream": False, }, @@ -1795,8 +2323,13 @@ async def debug_request_validation(request: Request): @app.get("/v1/auth/status") @rate_limit_endpoint("auth") -async def get_auth_status(request: Request): +async def get_auth_status( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), +): """Get Claude Code authentication status.""" + await verify_api_key(request, credentials) + from src.auth import auth_manager auth_info = get_claude_code_auth_info() @@ -1811,7 +2344,7 @@ async def get_auth_status(request: Request): if os.getenv("API_KEY") else ("runtime" if runtime_api_key else "none") ), - "version": "1.0.0", + "version": __version__, }, } @@ -1821,7 +2354,7 @@ async def get_session_stats( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), ): """Get session manager statistics.""" - stats = session_manager.get_stats() + stats = await session_manager.get_stats() return { "session_stats": stats, "cleanup_interval_minutes": session_manager.cleanup_interval_minutes, @@ -1832,7 +2365,7 @@ async def get_session_stats( @app.get("/v1/sessions") async def list_sessions(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)): """List all active sessions.""" - sessions = session_manager.list_sessions() + sessions = await session_manager.list_sessions() return SessionListResponse(sessions=sessions, total=len(sessions)) @@ -1841,7 +2374,7 @@ async def get_session( session_id: str, credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) ): """Get information about a specific session.""" - session = session_manager.get_session(session_id) + session = await session_manager.get_session(session_id) if not session: raise HTTPException(status_code=404, detail="Session not found") @@ -1853,7 +2386,7 @@ async def delete_session( session_id: str, credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) ): """Delete a specific session.""" - deleted = session_manager.delete_session(session_id) + deleted = await session_manager.delete_session(session_id) if not deleted: raise HTTPException(status_code=404, detail="Session not found") diff --git a/src/message_adapter.py b/src/message_adapter.py index 1c9d732..28e6a56 100644 --- a/src/message_adapter.py +++ b/src/message_adapter.py @@ -7,44 +7,66 @@ class MessageAdapter: """Converts between OpenAI message format and Claude Code prompts.""" @staticmethod - def messages_to_prompt(messages: List[Message]) -> tuple[str, Optional[str]]: + def messages_to_prompt(messages: List[Message], model: Optional[str] = None) -> tuple[str, Optional[str]]: """ Convert OpenAI messages to Claude Code prompt format. Returns (prompt, system_prompt) """ system_prompt = None conversation_parts = [] + + # Check if it's a Gemini model + is_gemini = model and ( + model.startswith("gemini") + or model in ["pro", "flash", "flash-lite", "auto"] + ) for message in messages: if message.role == "system": # Use the last system message as the system prompt system_prompt = message.content elif message.role == "user": - conversation_parts.append(f"Human: {message.content}") + if is_gemini: + conversation_parts.append(message.content) + else: + conversation_parts.append(f"Human: {message.content}") elif message.role == "assistant": - conversation_parts.append(f"Assistant: {message.content}") + if is_gemini: + conversation_parts.append(message.content) + else: + conversation_parts.append(f"Assistant: {message.content}") # Join conversation parts prompt = "\n\n".join(conversation_parts) # If the last message wasn't from the user, add a prompt for assistant if messages and messages[-1].role != "user": - prompt += "\n\nHuman: Please continue." + if not is_gemini: + prompt += "\n\nHuman: Please continue." return prompt, system_prompt @staticmethod - def filter_content(content: str) -> str: + def filter_content(content: str, prompt_echo: Optional[str] = None) -> str: """ Filter content for unsupported features and tool usage. Remove thinking blocks, tool calls, and image references. """ - if not content: - return content + if content is None: + return "" + + # Strip exact prompt echoes if provided (common with some CLI tools) + + if prompt_echo and content.startswith(prompt_echo): + content = content[len(prompt_echo):].strip() + # Also handle cases where Human: prefix is echoed + if content.startswith("Assistant:"): + content = content[len("Assistant:"):].strip() # Remove thinking blocks (common when tools are disabled but Claude tries to think) - thinking_pattern = r".*?" - content = re.sub(thinking_pattern, "", content, flags=re.DOTALL) + thinking_patterns = [r".*?", r".*?"] + for pattern in thinking_patterns: + content = re.sub(pattern, "", content, flags=re.DOTALL) # Extract content from attempt_completion blocks (these contain the actual user response) attempt_completion_pattern = r"(.*?)" @@ -62,23 +84,24 @@ def filter_content(content: str) -> str: if extracted_content: content = extracted_content else: - # Remove other tool usage blocks (when tools are disabled but Claude tries to use them) - tool_patterns = [ - r".*?", - r".*?", - r".*?", - r".*?", - r".*?", - r".*?", - r".*?", - r".*?", - r".*?", - r".*?", - r".*?", + # Instead of deleting all tool blocks, replace them with a short placeholder + # This prevents the message from being empty and explains what Claude was doing. + tool_tags = [ + "read_file", "write_file", "bash", "search_files", + "str_replace_editor", "args", "ask_followup_question", + "question", "follow_up", "suggest" ] - - for pattern in tool_patterns: - content = re.sub(pattern, "", content, flags=re.DOTALL) + + for tag in tool_tags: + pattern = f"<{tag}>(.*?)" + # If we find a tool tag, replace it with a shorter placeholder but keep some of the content + def replace_tool(match): + inner = match.group(1).strip() + # Only show first 50 chars of the tool command/arg to keep it clean + summary = (inner[:47] + "...") if len(inner) > 50 else inner + return f"\n[Tool: {tag} {summary}]\n" + + content = re.sub(pattern, replace_tool, content, flags=re.DOTALL) # Pattern to match image references or base64 data image_pattern = r"\[Image:.*?\]|data:image/.*?;base64,.*?(?=\s|$)" @@ -92,9 +115,10 @@ def replace_image(match): content = re.sub(r"\n\s*\n\s*\n", "\n\n", content) # Multiple newlines to double content = content.strip() - # If content is now empty or only whitespace, provide a fallback + # If content is now empty or only whitespace, and we originally HAD content, + # provide a more conversational fallback that indicates we understood but filtered. if not content or content.isspace(): - return "I understand you're testing the system. How can I help you today?" + return "I've processed your request. How else can I help you with this project today?" return content diff --git a/src/models.py b/src/models.py index 0642a47..0bdd131 100644 --- a/src/models.py +++ b/src/models.py @@ -83,6 +83,23 @@ class ChatCompletionRequest(BaseModel): stream_options: Optional[StreamOptions] = Field( default=None, description="Options for streaming responses" ) + # OpenAI reasoning_effort maps to SDK effort + reasoning_effort: Optional[Literal["low", "medium", "high"]] = Field( + default=None, description="Reasoning effort level (maps to SDK effort)" + ) + # OpenAI response_format maps to SDK output_format + response_format: Optional[Dict[str, Any]] = Field( + default=None, description="Output format specification (e.g. {'type': 'json_object'})" + ) + # Budget cap in USD (SDK extension) + max_budget_usd: Optional[float] = Field( + default=None, description="Maximum cost budget in USD" + ) + # Explicit thinking configuration (takes precedence over max_tokens β†’ max_thinking_tokens) + thinking: Optional[Dict[str, Any]] = Field( + default=None, + description="Thinking config e.g. {'type': 'enabled', 'budget_tokens': N}", + ) @field_validator("n") @classmethod @@ -108,7 +125,9 @@ def log_parameter_info(self): f"top_p={self.top_p} will be applied via system prompt (best-effort)" ) - if self.max_tokens is not None or self.max_completion_tokens is not None: + if self.thinking is None and ( + self.max_tokens is not None or self.max_completion_tokens is not None + ): max_val = self.max_completion_tokens or self.max_tokens info_messages.append( f"max_tokens={max_val} will be mapped to max_thinking_tokens (best-effort)" @@ -185,21 +204,39 @@ def to_claude_options(self) -> Dict[str, Any]: if self.model: options["model"] = self.model - # Map max_tokens to max_thinking_tokens (best effort) - max_token_value = self.max_completion_tokens or self.max_tokens - if max_token_value is not None: - # Claude SDK doesn't have exact token limiting, but we can try max_thinking_tokens - # This is approximate and may not work as expected - options["max_thinking_tokens"] = max_token_value - logger.info( - f"Mapped max_tokens={max_token_value} to max_thinking_tokens (approximate behavior)" - ) + # thinking config (explicit, takes precedence over max_tokens mapping) + if self.thinking is not None: + options["thinking"] = self.thinking + else: + # Map max_tokens to max_thinking_tokens (best effort, deprecated but still works) + max_token_value = self.max_completion_tokens or self.max_tokens + if max_token_value is not None: + options["max_thinking_tokens"] = max_token_value + logger.info( + f"Mapped max_tokens={max_token_value} to max_thinking_tokens (approximate behavior)" + ) + elif self.model and (self.model.startswith("claude-4") or "4-6" in self.model or "4-5" in self.model): + # Default to 4000 for Claude 4 models if not specified + options["max_thinking_tokens"] = 4000 + logger.debug("Using default max_thinking_tokens=4000 for Claude 4 model") + + # reasoning_effort β†’ effort + if self.reasoning_effort is not None: + options["effort"] = self.reasoning_effort - # Use user field for session identification if provided + # response_format β†’ output_format + if self.response_format is not None: + options["output_format"] = self.response_format + + # Forward user identifier to SDK if self.user: - # Could be used for analytics/logging or session tracking + options["user"] = self.user logger.info(f"Request from user: {self.user}") + # Budget cap + if self.max_budget_usd is not None: + options["max_budget_usd"] = self.max_budget_usd + return options @@ -447,6 +484,38 @@ class AnthropicMessagesRequest(BaseModel): stop_sequences: Optional[List[str]] = None stream: Optional[bool] = False metadata: Optional[Dict[str, Any]] = None + session_id: Optional[str] = Field(default=None) + enable_tools: Optional[bool] = Field(default=False) + + def get_sampling_instructions(self) -> Optional[str]: + """Generate sampling instructions based on temperature and top_p.""" + instructions = [] + + if self.temperature is not None and self.temperature != 1.0: + if self.temperature < 0.3: + instructions.append( + "Be highly focused and deterministic in your responses. Choose the most likely and predictable options." + ) + elif self.temperature < 0.7: + instructions.append( + "Be somewhat focused and consistent in your responses, preferring reliable and expected solutions." + ) + elif self.temperature > 1.0: + instructions.append( + "Be creative and varied in your responses, exploring different approaches and possibilities." + ) + + if self.top_p is not None and self.top_p < 1.0: + if self.top_p < 0.5: + instructions.append( + "Focus on the most probable and mainstream solutions, avoiding less likely alternatives." + ) + elif self.top_p < 0.9: + instructions.append( + "Prefer well-established and common approaches over unusual ones." + ) + + return " ".join(instructions) if instructions else None def to_openai_messages(self) -> List[Message]: """Convert Anthropic messages to OpenAI format.""" @@ -481,3 +550,47 @@ class AnthropicMessagesResponse(BaseModel): stop_reason: Optional[Literal["end_turn", "max_tokens", "stop_sequence"]] = "end_turn" stop_sequence: Optional[str] = None usage: AnthropicUsage + + +class AnthropicMessageStartEvent(BaseModel): + """Anthropic SSE message_start event.""" + + type: Literal["message_start"] = "message_start" + message: Dict[str, Any] + + +class AnthropicContentBlockStartEvent(BaseModel): + """Anthropic SSE content_block_start event.""" + + type: Literal["content_block_start"] = "content_block_start" + index: int + content_block: Dict[str, Any] + + +class AnthropicContentBlockDeltaEvent(BaseModel): + """Anthropic SSE content_block_delta event.""" + + type: Literal["content_block_delta"] = "content_block_delta" + index: int + delta: Dict[str, Any] + + +class AnthropicContentBlockStopEvent(BaseModel): + """Anthropic SSE content_block_stop event.""" + + type: Literal["content_block_stop"] = "content_block_stop" + index: int + + +class AnthropicMessageDeltaEvent(BaseModel): + """Anthropic SSE message_delta event (carries stop_reason and usage).""" + + type: Literal["message_delta"] = "message_delta" + delta: Dict[str, Any] + usage: Dict[str, Any] + + +class AnthropicMessageStopEvent(BaseModel): + """Anthropic SSE message_stop event.""" + + type: Literal["message_stop"] = "message_stop" diff --git a/src/parameter_validator.py b/src/parameter_validator.py index e45452f..664eeda 100644 --- a/src/parameter_validator.py +++ b/src/parameter_validator.py @@ -5,7 +5,7 @@ import logging from typing import Dict, Any, List, Optional from src.models import ChatCompletionRequest -from src.constants import CLAUDE_MODELS +from src.constants import CLAUDE_MODELS, GLM_MODELS logger = logging.getLogger(__name__) @@ -13,8 +13,13 @@ class ParameterValidator: """Validates and maps OpenAI Chat Completions parameters to Claude Code SDK options.""" - # Use models from constants (single source of truth) - SUPPORTED_MODELS = set(CLAUDE_MODELS) + # Models that route through claude_cli and must pass model validation. + # Includes GLM passthrough models because get_cli_for_model() sends them to + # claude_cli (served via Claude Code + ANTHROPIC_BASE_URL proxy); without them + # here, every GLM request logs a spurious "not in supported list" warning. + # Gemini models are absent on purpose: they route to gemini_cli, which skips + # validate_model entirely. + SUPPORTED_MODELS = set(CLAUDE_MODELS) | set(GLM_MODELS) # Valid permission modes for Claude Code SDK VALID_PERMISSION_MODES = {"default", "acceptEdits", "bypassPermissions", "plan"} diff --git a/src/session_manager.py b/src/session_manager.py index 8423878..69aa868 100644 --- a/src/session_manager.py +++ b/src/session_manager.py @@ -1,9 +1,8 @@ import asyncio import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Dict, List, Optional, Tuple from dataclasses import dataclass, field -from threading import Lock from src.models import Message, SessionInfo @@ -16,14 +15,14 @@ class Session: session_id: str messages: List[Message] = field(default_factory=list) - created_at: datetime = field(default_factory=datetime.utcnow) - last_accessed: datetime = field(default_factory=datetime.utcnow) - expires_at: datetime = field(default_factory=lambda: datetime.utcnow() + timedelta(hours=1)) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + last_accessed: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + expires_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc) + timedelta(hours=1)) def touch(self): """Update last accessed time and extend expiration.""" - self.last_accessed = datetime.utcnow() - self.expires_at = datetime.utcnow() + timedelta(hours=1) + self.last_accessed = datetime.now(timezone.utc) + self.expires_at = datetime.now(timezone.utc) + timedelta(hours=1) def add_messages(self, messages: List[Message]): """Add new messages to the session.""" @@ -36,7 +35,7 @@ def get_all_messages(self) -> List[Message]: def is_expired(self) -> bool: """Check if the session has expired.""" - return datetime.utcnow() > self.expires_at + return datetime.now(timezone.utc) > self.expires_at def to_session_info(self) -> SessionInfo: """Convert to SessionInfo model.""" @@ -54,7 +53,7 @@ class SessionManager: def __init__(self, default_ttl_hours: int = 1, cleanup_interval_minutes: int = 5): self.sessions: Dict[str, Session] = {} - self.lock = Lock() + self.lock = asyncio.Lock() self.default_ttl_hours = default_ttl_hours self.cleanup_interval_minutes = cleanup_interval_minutes self._cleanup_task = None @@ -68,7 +67,7 @@ async def cleanup_loop(): try: while True: await asyncio.sleep(self.cleanup_interval_minutes * 60) - self._cleanup_expired_sessions() + await self._cleanup_expired_sessions() except asyncio.CancelledError: logger.info("Session cleanup task cancelled") raise @@ -82,9 +81,9 @@ async def cleanup_loop(): except RuntimeError: logger.warning("No running event loop, automatic session cleanup disabled") - def _cleanup_expired_sessions(self): + async def _cleanup_expired_sessions(self): """Remove expired sessions.""" - with self.lock: + async with self.lock: expired_sessions = [ session_id for session_id, session in self.sessions.items() if session.is_expired() ] @@ -93,9 +92,9 @@ def _cleanup_expired_sessions(self): del self.sessions[session_id] logger.info(f"Cleaned up expired session: {session_id}") - def get_or_create_session(self, session_id: str) -> Session: + async def get_or_create_session(self, session_id: str) -> Session: """Get existing session or create a new one.""" - with self.lock: + async with self.lock: if session_id in self.sessions: session = self.sessions[session_id] if session.is_expired(): @@ -113,9 +112,9 @@ def get_or_create_session(self, session_id: str) -> Session: return session - def get_session(self, session_id: str) -> Optional[Session]: + async def get_session(self, session_id: str) -> Optional[Session]: """Get existing session without creating new one.""" - with self.lock: + async with self.lock: session = self.sessions.get(session_id) if session and not session.is_expired(): session.touch() @@ -126,18 +125,18 @@ def get_session(self, session_id: str) -> Optional[Session]: logger.info(f"Removed expired session: {session_id}") return None - def delete_session(self, session_id: str) -> bool: + async def delete_session(self, session_id: str) -> bool: """Delete a session.""" - with self.lock: + async with self.lock: if session_id in self.sessions: del self.sessions[session_id] logger.info(f"Deleted session: {session_id}") return True return False - def list_sessions(self) -> List[SessionInfo]: + async def list_sessions(self) -> List[SessionInfo]: """List all active sessions.""" - with self.lock: + async with self.lock: # Clean up expired sessions first expired_sessions = [ session_id for session_id, session in self.sessions.items() if session.is_expired() @@ -149,7 +148,7 @@ def list_sessions(self) -> List[SessionInfo]: # Return active sessions return [session.to_session_info() for session in self.sessions.values()] - def process_messages( + async def process_messages( self, messages: List[Message], session_id: Optional[str] = None ) -> Tuple[List[Message], Optional[str]]: """ @@ -163,10 +162,10 @@ def process_messages( return messages, None # Session mode - get or create session and merge messages - session = self.get_or_create_session(session_id) + session = await self.get_or_create_session(session_id) - # Add new messages to session - session.add_messages(messages) + # Replace session messages with client-provided history (client sends full history each request) + session.messages = list(messages) # Return all messages in the session for Claude all_messages = session.get_all_messages() @@ -177,19 +176,19 @@ def process_messages( return all_messages, session_id - def add_assistant_response(self, session_id: Optional[str], assistant_message: Message): + async def add_assistant_response(self, session_id: Optional[str], assistant_message: Message): """Add assistant response to session if session mode is active.""" if session_id is None: return - session = self.get_session(session_id) + session = await self.get_session(session_id) if session: session.add_messages([assistant_message]) logger.info(f"Added assistant response to session {session_id}") - def get_stats(self) -> Dict[str, int]: + async def get_stats(self) -> Dict[str, int]: """Get session manager statistics.""" - with self.lock: + async with self.lock: active_sessions = sum(1 for s in self.sessions.values() if not s.is_expired()) expired_sessions = sum(1 for s in self.sessions.values() if s.is_expired()) total_messages = sum(len(s.messages) for s in self.sessions.values()) @@ -200,12 +199,12 @@ def get_stats(self) -> Dict[str, int]: "total_messages": total_messages, } - def shutdown(self): + async def shutdown(self): """Shutdown the session manager and cleanup tasks.""" if self._cleanup_task: self._cleanup_task.cancel() - with self.lock: + async with self.lock: self.sessions.clear() logger.info("Session manager shutdown complete") diff --git a/src/tool_manager.py b/src/tool_manager.py index a481d4a..55e6c85 100644 --- a/src/tool_manager.py +++ b/src/tool_manager.py @@ -8,7 +8,7 @@ from typing import Dict, List, Optional, Set from dataclasses import dataclass, field from threading import Lock -from datetime import datetime +from datetime import datetime, timezone from src.constants import CLAUDE_TOOLS, DEFAULT_ALLOWED_TOOLS, DEFAULT_DISALLOWED_TOOLS @@ -56,7 +56,7 @@ class ToolMetadata: "run_in_background": "Run command in background", }, examples=["Run npm install", "Execute git status", "List directory contents"], - is_safe=True, + is_safe=False, requires_network=False, ), "Glob": ToolMetadata( @@ -245,8 +245,8 @@ class ToolConfiguration: allowed_tools: Optional[List[str]] = None disallowed_tools: Optional[List[str]] = None - created_at: datetime = field(default_factory=datetime.utcnow) - updated_at: datetime = field(default_factory=datetime.utcnow) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) def get_effective_tools(self) -> Set[str]: """ @@ -280,7 +280,7 @@ def update( self.allowed_tools = allowed_tools if disallowed_tools is not None: self.disallowed_tools = disallowed_tools - self.updated_at = datetime.utcnow() + self.updated_at = datetime.now(timezone.utc) class ToolManager: diff --git a/tests/conftest.py b/tests/conftest.py index d5ab386..7b3c238 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,8 @@ import pytest import requests +MAX_TOKENS = 4096 + # Check if server is running for integration tests def is_server_running(base_url: str = "http://localhost:8000") -> bool: diff --git a/tests/test_claude_cli_unit.py b/tests/test_claude_cli_unit.py index c67c7fe..c2b143e 100644 --- a/tests/test_claude_cli_unit.py +++ b/tests/test_claude_cli_unit.py @@ -548,7 +548,8 @@ async def mock_query(prompt, options): assert len(captured_options) == 1 opts = captured_options[0] - assert opts.system_prompt == {"type": "text", "text": "You are helpful"} + # Plain str, not a dict: the SDK only emits --system-prompt for str. + assert opts.system_prompt == "You are helpful" @pytest.mark.asyncio async def test_run_completion_with_model(self, cli_instance): @@ -561,7 +562,9 @@ async def mock_query(prompt, options): yield mock_message with patch("src.claude_cli.query", mock_query): - async for _ in cli_instance.run_completion("Hello", model="claude-3-opus"): + async for _ in cli_instance.run_completion( + "Hello", claude_options={"model": "claude-3-opus"} + ): pass assert captured_options[0].model == "claude-3-opus" @@ -579,8 +582,7 @@ async def mock_query(prompt, options): with patch("src.claude_cli.query", mock_query): async for _ in cli_instance.run_completion( "Hello", - allowed_tools=["Bash", "Read"], - disallowed_tools=["Task"], + claude_options={"allowed_tools": ["Bash", "Read"], "disallowed_tools": ["Task"]}, ): pass @@ -598,7 +600,9 @@ async def mock_query(prompt, options): yield mock_message with patch("src.claude_cli.query", mock_query): - async for _ in cli_instance.run_completion("Hello", permission_mode="acceptEdits"): + async for _ in cli_instance.run_completion( + "Hello", claude_options={"permission_mode": "acceptEdits"} + ): pass assert captured_options[0].permission_mode == "acceptEdits" @@ -617,7 +621,7 @@ async def mock_query(prompt, options): async for _ in cli_instance.run_completion("Hello", continue_session=True): pass - assert captured_options[0].continue_session is True + assert captured_options[0].continue_conversation is True @pytest.mark.asyncio async def test_run_completion_resume_session(self, cli_instance): diff --git a/tests/test_dynamic_models.py b/tests/test_dynamic_models.py index ab8bf6f..87b2920 100644 --- a/tests/test_dynamic_models.py +++ b/tests/test_dynamic_models.py @@ -9,7 +9,7 @@ @pytest.mark.asyncio async def test_get_available_models_uses_anthropic_models_api(monkeypatch): - main._model_list_cache = {"expires_at": 0.0, "models": None} + monkeypatch.setattr(main, "_model_list_cache", {"expires_at": 0.0, "models": None}) async def fake_fetch(): return [ @@ -32,7 +32,7 @@ async def fake_fetch(): @pytest.mark.asyncio async def test_get_available_models_falls_back_to_constants(monkeypatch): - main._model_list_cache = {"expires_at": 0.0, "models": None} + monkeypatch.setattr(main, "_model_list_cache", {"expires_at": 0.0, "models": None}) async def fake_fetch(): return None @@ -47,7 +47,7 @@ async def fake_fetch(): @pytest.mark.asyncio async def test_model_override_skips_live_fetch(monkeypatch): - main._model_list_cache = {"expires_at": 0.0, "models": None} + monkeypatch.setattr(main, "_model_list_cache", {"expires_at": 0.0, "models": None}) async def fake_fetch(): raise AssertionError("override should not call live Anthropic API") @@ -93,7 +93,7 @@ def test_fallback_objects_include_created_field(): @pytest.mark.asyncio async def test_concurrent_calls_only_fetch_once(monkeypatch): """Lock + double-check should prevent thundering-herd on cache expiry.""" - main._model_list_cache = {"expires_at": 0.0, "models": None} + monkeypatch.setattr(main, "_model_list_cache", {"expires_at": 0.0, "models": None}) call_count = 0 async def fake_fetch(): @@ -114,7 +114,7 @@ async def fake_fetch(): @pytest.mark.asyncio async def test_failed_fetch_uses_short_error_ttl(monkeypatch): - main._model_list_cache = {"expires_at": 0.0, "models": None} + monkeypatch.setattr(main, "_model_list_cache", {"expires_at": 0.0, "models": None}) async def fake_fetch(): return None @@ -151,8 +151,8 @@ def test_pick_latest_sonnet_returns_none_when_no_sonnet(): @pytest.mark.asyncio async def test_resolve_default_model_sets_constants(monkeypatch): - main._model_list_cache = {"expires_at": 0.0, "models": None} - constants.RESOLVED_DEFAULT_MODEL = None + monkeypatch.setattr(main, "_model_list_cache", {"expires_at": 0.0, "models": None}) + monkeypatch.setattr(constants, "RESOLVED_DEFAULT_MODEL", None) async def fake_fetch(): return [ @@ -184,7 +184,7 @@ async def fake_fetch(): @pytest.mark.asyncio async def test_resolve_default_model_skips_without_api_key(monkeypatch, caplog): """No ANTHROPIC_API_KEY -> skip live discovery, log clearly, use fallback.""" - constants.RESOLVED_DEFAULT_MODEL = None + monkeypatch.setattr(constants, "RESOLVED_DEFAULT_MODEL", None) async def fake_fetch(): raise AssertionError("should not call live API without ANTHROPIC_API_KEY") @@ -203,8 +203,8 @@ async def fake_fetch(): @pytest.mark.asyncio async def test_resolve_default_model_honors_env_override(monkeypatch): - main._model_list_cache = {"expires_at": 0.0, "models": None} - constants.RESOLVED_DEFAULT_MODEL = None + monkeypatch.setattr(main, "_model_list_cache", {"expires_at": 0.0, "models": None}) + monkeypatch.setattr(constants, "RESOLVED_DEFAULT_MODEL", None) async def fake_fetch(): raise AssertionError("env override should short-circuit fetch") diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 3592818..7b1d6fb 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -7,7 +7,7 @@ import pytest import requests -from tests.conftest import requires_server +from tests.conftest import requires_server, MAX_TOKENS import json BASE_URL = "http://localhost:8000" @@ -57,7 +57,7 @@ def test_models(): @requires_server def test_chat_completion(): - print("\nTesting /v1/chat/completions endpoint...") + print("\nTesting /v1/messages endpoint...") try: payload = { "model": "claude-3-5-haiku-20241022", # Use fastest model @@ -67,11 +67,11 @@ def test_chat_completion(): "content": "Say 'Hello, SDK integration working!' and nothing else.", } ], - "max_tokens": 50, + "max_tokens": MAX_TOKENS, } response = requests.post( - f"{BASE_URL}/v1/chat/completions", + f"{BASE_URL}/v1/messages", json=payload, headers={"Content-Type": "application/json"}, ) @@ -80,7 +80,7 @@ def test_chat_completion(): if response.status_code == 200: result = response.json() - content = result.get("choices", [{}])[0].get("message", {}).get("content", "") + content = result.get("content", [{}])[0].get("text", "") print(f" Response: {content}") print(f" Usage: {result.get('usage', {})}") return True diff --git a/tests/test_gemini_cli_unit.py b/tests/test_gemini_cli_unit.py new file mode 100644 index 0000000..5a3423a --- /dev/null +++ b/tests/test_gemini_cli_unit.py @@ -0,0 +1,95 @@ +import pytest +import json +import asyncio +from unittest.mock import patch, MagicMock, AsyncMock +from src.gemini_cli import GeminiCodeCLI + +@pytest.fixture +def gemini_cli(): + return GeminiCodeCLI() + +@pytest.mark.asyncio +async def test_verify_cli_success(gemini_cli): + # Mock NDJSON output from gemini CLI for a "Hello" query + mock_output = [ + json.dumps({"type": "init", "session_id": "test-session", "model": "gemini-3"}), + json.dumps({"type": "message", "content": "Hello"}), + json.dumps({"type": "result", "usage": {"prompt_tokens": 10, "completion_tokens": 5}, "stop_reason": "STOP"}), + ] + + with patch("asyncio.create_subprocess_exec") as mock_exec: + mock_process = MagicMock() + # Mock readline to return the NDJSON chunks + mock_process.stdout.readline = AsyncMock(side_effect=[line.encode() + b"\n" for line in mock_output] + [b""]) + mock_process.wait = AsyncMock() + mock_process.returncode = 0 + mock_exec.return_value = mock_process + + result = await gemini_cli.verify_cli() + assert result is True + # Verify it called gemini with the prewarm prompt + mock_exec.assert_called_once() + args, kwargs = mock_exec.call_args + assert "--prompt" in args + assert "Hello" in args + +@pytest.mark.asyncio +async def test_verify_cli_failure(gemini_cli): + with patch("asyncio.create_subprocess_exec") as mock_exec: + mock_process = MagicMock() + # Mock immediate exit with error or no output + mock_process.stdout.readline = AsyncMock(return_value=b"") + mock_process.wait = AsyncMock() + mock_process.returncode = 1 + mock_exec.return_value = mock_process + + result = await gemini_cli.verify_cli() + assert result is False + +@pytest.mark.asyncio +async def test_run_completion_streaming(gemini_cli): + # Mock NDJSON output from gemini CLI + mock_output = [ + json.dumps({"type": "init", "session_id": "test-session", "model": "gemini-3-pro-preview"}), + json.dumps({"type": "message", "content": "Hello"}), + json.dumps({"type": "message", "content": " world"}), + json.dumps({"type": "result", "usage": {"prompt_tokens": 10, "completion_tokens": 5}, "stop_reason": "STOP"}), + ] + + with patch("asyncio.create_subprocess_exec") as mock_exec: + mock_process = MagicMock() + mock_process.stdout.readline = AsyncMock(side_effect=[line.encode() + b"\n" for line in mock_output] + [b""]) + mock_process.wait = AsyncMock() + mock_process.returncode = 0 + mock_exec.return_value = mock_process + + chunks = [] + async for chunk in gemini_cli.run_completion("Hi"): + chunks.append(chunk) + + assert len(chunks) == 4 + assert chunks[1]["content"] == "Hello" + assert chunks[2]["content"] == " world" + assert chunks[0]["session_id"] == "test-session" + +def test_parse_message(gemini_cli): + messages = [ + {"type": "message", "content": "Hello"}, + {"type": "message", "content": " world!"} + ] + assert gemini_cli.parse_message(messages) == "Hello world!" + +def test_extract_metadata(gemini_cli): + messages = [ + {"type": "init", "session_id": "uuid-123", "model": "gemini-3"}, + {"type": "result", "usage": {"input_tokens": 5, "output_tokens": 10}} + ] + metadata = gemini_cli.extract_metadata(messages) + assert metadata["session_id"] == "uuid-123" + assert metadata["model"] == "gemini-3" + assert metadata["usage"]["input_tokens"] == 5 + +def test_map_stop_reason_openai(gemini_cli): + assert gemini_cli.map_stop_reason_openai("MAX_TOKENS") == "length" + assert gemini_cli.map_stop_reason_openai("STOP") == "stop" + assert gemini_cli.map_stop_reason_openai(None) == "stop" diff --git a/tests/test_glm_passthrough_unit.py b/tests/test_glm_passthrough_unit.py new file mode 100644 index 0000000..326206b --- /dev/null +++ b/tests/test_glm_passthrough_unit.py @@ -0,0 +1,41 @@ +"""Unit tests for GLM-5.2 passthrough: advertisement + routing.""" + +from src.constants import GLM_MODELS, PASSTHROUGH_MODELS +from src.main import _append_passthrough, get_cli_for_model, claude_cli, gemini_cli + + +def test_glm_model_listed(): + assert "glm-5.2" in GLM_MODELS + assert "glm-5.2[1m]" in GLM_MODELS + assert "glm-5.2" in PASSTHROUGH_MODELS + assert "glm-5.2[1m]" in PASSTHROUGH_MODELS + + +def test_append_passthrough_adds_glm(): + result = _append_passthrough([{"id": "claude-sonnet-4-6", "object": "model"}]) + ids = [m["id"] for m in result] + assert "glm-5.2" in ids + assert "claude-sonnet-4-6" in ids + + +def test_append_passthrough_dedupes(): + models = [{"id": "glm-5.2", "object": "model"}] + result = _append_passthrough(models) + assert sum(1 for m in result if m["id"] == "glm-5.2") == 1 + + +def test_glm_routes_to_claude_cli(): + assert get_cli_for_model("glm-5.2") is claude_cli + assert get_cli_for_model("glm-5.2") is not gemini_cli + + +def test_glm_in_supported_models(): + """GLM routes through claude_cli, so validate_model must recognize it. + + Without GLM in SUPPORTED_MODELS every GLM request logs a spurious + "not in the known supported models list" warning. + """ + from src.parameter_validator import ParameterValidator + + for model in GLM_MODELS: + assert model in ParameterValidator.SUPPORTED_MODELS diff --git a/tests/test_message_adapter_unit.py b/tests/test_message_adapter_unit.py index 90f3c52..d9d2c82 100644 --- a/tests/test_message_adapter_unit.py +++ b/tests/test_message_adapter_unit.py @@ -86,14 +86,43 @@ def test_empty_messages_list(self): assert prompt == "" assert system is None + def test_gemini_formatting_no_prefixes(self): + """Gemini models should not have Human:/Assistant: prefixes.""" + messages = [ + Message(role="user", content="Hello"), + Message(role="assistant", content="Hi!"), + Message(role="user", content="What's up?"), + ] + prompt, system = MessageAdapter.messages_to_prompt(messages, model="gemini-3-flash-preview") + + assert "Human:" not in prompt + assert "Assistant:" not in prompt + assert "Hello" in prompt + assert "Hi!" in prompt + assert "What's up?" in prompt + + def test_gemini_no_continue_added(self): + """Gemini models should not have 'Please continue' added.""" + messages = [ + Message(role="user", content="Hello"), + Message(role="assistant", content="Hi!"), + ] + prompt, system = MessageAdapter.messages_to_prompt(messages, model="flash") + + assert "Please continue" not in prompt + class TestFilterContent: """Test MessageAdapter.filter_content()""" - def test_empty_content_returns_empty(self): - """Empty content returns empty.""" - assert MessageAdapter.filter_content("") == "" - assert MessageAdapter.filter_content(None) is None + def test_empty_content_returns_fallback(self): + """Empty content returns fallback message.""" + result = MessageAdapter.filter_content("") + assert "How else can I help you with this project today?" in result + + def test_none_content_returns_empty_string(self): + """None content returns empty string.""" + assert MessageAdapter.filter_content(None) == "" def test_plain_text_unchanged(self): """Plain text content is unchanged.""" @@ -101,6 +130,20 @@ def test_plain_text_unchanged(self): result = MessageAdapter.filter_content(content) assert result == content + def test_strips_prompt_echo(self): + """Should strip the prompt echo from the beginning of the response.""" + prompt = "Explain relativity" + content = "Explain relativityRelativity is a theory..." + result = MessageAdapter.filter_content(content, prompt_echo=prompt) + assert result == "Relativity is a theory..." + + def test_strips_assistant_prefix_after_echo(self): + """Should strip Assistant: prefix if it remains after echo stripping.""" + prompt = "Human: Hello" + content = "Human: Hello\n\nAssistant: Hi there!" + result = MessageAdapter.filter_content(content, prompt_echo=prompt) + assert result == "Hi there!" + def test_removes_thinking_blocks(self): """Thinking blocks are removed.""" content = "Let me think about this...Here is my answer." @@ -142,77 +185,78 @@ def test_extracts_result_from_attempt_completion(self): assert result == "The extracted result." - def test_removes_read_file_blocks(self): - """read_file blocks are removed.""" - content = "Response path/to/file.txt more text" + def test_removes_thought_blocks(self): + """Thought blocks (alternative thinking tag) are removed.""" + content = "Thinking...Answer." result = MessageAdapter.filter_content(content) + assert "" not in result + assert "Thinking" not in result + assert result == "Answer." - assert "" not in result - assert "path/to/file" not in result - - def test_removes_write_file_blocks(self): - """write_file blocks are removed.""" - content = "Response content more text" + def test_replaces_tool_tags_with_placeholders(self): + """Tool tags are replaced with placeholders instead of deleted.""" + content = "Checking files: src/main.py" result = MessageAdapter.filter_content(content) + + assert "" not in result + assert "[Tool: read_file src/main.py]" in result - assert "" not in result - - def test_removes_bash_blocks(self): - """bash blocks are removed.""" - content = "Here's the output: ls -la done" + def test_replaces_bash_with_placeholder(self): + """Bash blocks are replaced with placeholders.""" + content = "Running command: ls -la" result = MessageAdapter.filter_content(content) assert "" not in result - assert "ls -la" not in result - - def test_removes_search_files_blocks(self): - """search_files blocks are removed.""" - content = "patternResult" - result = MessageAdapter.filter_content(content) - - assert "" not in result + assert "[Tool: bash ls -la]" in result - def test_removes_str_replace_editor_blocks(self): - """str_replace_editor blocks are removed.""" - content = "editDone" + def test_truncates_long_tool_placeholders(self): + """Long tool arguments are truncated in the placeholder.""" + long_arg = "a" * 100 + content = f"{long_arg}" result = MessageAdapter.filter_content(content) + + assert len(result) < 100 + assert "..." in result - assert "" not in result - - def test_removes_args_blocks(self): - """args blocks are removed.""" + def test_replaces_args_blocks(self): + """args blocks are replaced with placeholders.""" content = "Command --flag value executed" result = MessageAdapter.filter_content(content) assert "" not in result + assert "[Tool: args --flag value]" in result - def test_removes_ask_followup_question_blocks(self): - """ask_followup_question blocks are removed.""" + def test_replaces_ask_followup_question_blocks(self): + """ask_followup_question blocks are replaced with placeholders.""" content = "What do you mean?Ok" result = MessageAdapter.filter_content(content) assert "" not in result + assert "[Tool: ask_followup_question What do you mean?]" in result - def test_removes_question_blocks(self): - """question blocks are removed.""" + def test_replaces_question_blocks(self): + """question blocks are replaced with placeholders.""" content = "Do you want to proceed?Answer" result = MessageAdapter.filter_content(content) assert "" not in result + assert "[Tool: question Do you want to proceed?]" in result - def test_removes_follow_up_blocks(self): - """follow_up blocks are removed.""" + def test_replaces_follow_up_blocks(self): + """follow_up blocks are replaced with placeholders.""" content = "Please clarifyResponse" result = MessageAdapter.filter_content(content) assert "" not in result + assert "[Tool: follow_up Please clarify]" in result - def test_removes_suggest_blocks(self): - """suggest blocks are removed.""" + def test_replaces_suggest_blocks(self): + """suggest blocks are replaced with placeholders.""" content = "try thisSuggestion" result = MessageAdapter.filter_content(content) assert "" not in result + assert "[Tool: suggest try this]" in result def test_replaces_image_references(self): """Image references are replaced with placeholder.""" @@ -243,14 +287,14 @@ def test_empty_after_filtering_returns_fallback(self): content = "Only thinking content" result = MessageAdapter.filter_content(content) - assert "How can I help you today?" in result + assert "How else can I help you with this project today?" in result def test_whitespace_only_after_filtering_returns_fallback(self): """If content is only whitespace after filtering, returns fallback.""" content = "content \n \n " result = MessageAdapter.filter_content(content) - assert "How can I help you today?" in result + assert "How else can I help you with this project today?" in result class TestFormatClaudeResponse: diff --git a/tests/test_message_parser_unit.py b/tests/test_message_parser_unit.py new file mode 100644 index 0000000..b0717fa --- /dev/null +++ b/tests/test_message_parser_unit.py @@ -0,0 +1,71 @@ +"""Unit tests for the typed SDK message parser (_message_to_dict).""" + +from claude_agent_sdk import AssistantMessage, ResultMessage, SystemMessage, TextBlock + +from src.claude_cli import _message_to_dict + + +def test_assistant_message_keeps_textblock_content(): + msg = AssistantMessage(content=[TextBlock(text="hello world")], model="glm-5.2") + d = _message_to_dict(msg) + assert d["type"] == "assistant" + assert isinstance(d["content"], list) + assert d["content"][0].text == "hello world" + + +def test_result_message_fields_preserved(): + msg = ResultMessage( + subtype="success", + duration_ms=100, + duration_api_ms=80, + is_error=False, + num_turns=2, + session_id="sess-1", + result="done", + total_cost_usd=0.01, + usage={"input_tokens": 10, "output_tokens": 5}, + stop_reason="end_turn", + ) + d = _message_to_dict(msg) + assert d["type"] == "result" + assert d["subtype"] == "success" + assert d["result"] == "done" + assert d["session_id"] == "sess-1" + assert d["total_cost_usd"] == 0.01 + assert d["num_turns"] == 2 + assert d["is_error"] is False + assert d["stop_reason"] == "end_turn" + assert d["duration_ms"] == 100 + assert d["usage"]["input_tokens"] == 10 + assert d["usage"]["output_tokens"] == 5 + + +def test_system_message_init_data_preserved(): + msg = SystemMessage( + subtype="init", + data={"session_id": "sess-1", "model": "glm-5.2"}, + ) + d = _message_to_dict(msg) + assert d["type"] == "system" + assert d["subtype"] == "init" + assert d["data"]["session_id"] == "sess-1" + assert d["data"]["model"] == "glm-5.2" + + +def test_dict_passthrough_unchanged(): + original = { + "type": "result", + "subtype": "error_during_execution", + "is_error": True, + "error_message": "boom", + } + assert _message_to_dict(original) is original + + +def test_unknown_object_falls_back_to_attr_copy(): + class Unknown: + type = "weird" + foo = "bar" + + d = _message_to_dict(Unknown()) + assert d.get("foo") == "bar" diff --git a/tests/test_non_streaming.py b/tests/test_non_streaming.py index ec94673..c342653 100644 --- a/tests/test_non_streaming.py +++ b/tests/test_non_streaming.py @@ -8,7 +8,7 @@ import pytest import requests -from tests.conftest import requires_server +from tests.conftest import requires_server, MAX_TOKENS # Set debug mode os.environ["DEBUG_MODE"] = "true" @@ -23,14 +23,14 @@ def test_non_streaming(): request_data = { "model": "claude-3-7-sonnet-20250219", "messages": [{"role": "user", "content": "What is 2+2?"}], - "stream": False, + "max_tokens": MAX_TOKENS, "temperature": 0.0, } try: # Send non-streaming request response = requests.post( - "http://localhost:8000/v1/chat/completions", json=request_data, timeout=30 + "http://localhost:8000/v1/messages", json=request_data, timeout=30 ) print(f"βœ… Response status: {response.status_code}") @@ -43,9 +43,8 @@ def test_non_streaming(): data = response.json() # Check response structure - if "choices" in data and len(data["choices"]) > 0: - message = data["choices"][0]["message"] - content = message["content"] + if "content" in data and len(data["content"]) > 0: + content = data["content"][0]["text"] print(f"πŸ“Š Response content: {content}") diff --git a/tests/test_parameter_mapping.py b/tests/test_parameter_mapping.py index d6bcaa2..e1177c6 100644 --- a/tests/test_parameter_mapping.py +++ b/tests/test_parameter_mapping.py @@ -12,7 +12,7 @@ import requests from typing import Dict, Any -from tests.conftest import requires_server +from tests.conftest import requires_server, MAX_TOKENS # Test server URL BASE_URL = "http://localhost:8000" @@ -20,26 +20,25 @@ @requires_server def test_basic_completion(): - """Test basic chat completion with OpenAI parameters.""" + """Test basic chat completion with Anthropic parameters.""" print("=== Testing Basic Completion ===") payload = { "model": "claude-3-5-sonnet-20241022", + "system": "You are a helpful assistant.", "messages": [ - {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Say hello in a creative way."}, ], - "temperature": 0.7, # Will be ignored with warning - "max_tokens": 100, # Will be ignored with warning - "stream": False, + "temperature": 0.7, + "max_tokens": MAX_TOKENS, } - response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload) + response = requests.post(f"{BASE_URL}/v1/messages", json=payload) if response.status_code == 200: print("βœ… Request successful") result = response.json() - print(f"Response: {result['choices'][0]['message']['content'][:100]}...") + print(f"Response: {result['content'][0]['text'][:100]}...") else: print(f"❌ Request failed: {response.status_code}") print(response.text) @@ -104,51 +103,33 @@ def test_compatibility_check(): print(response.text) -@requires_server -def test_parameter_validation(): - """Test parameter validation (should fail).""" - print("\n=== Testing Parameter Validation ===") - - # Test with n > 1 (should fail) - payload = { - "model": "claude-3-5-sonnet-20241022", - "messages": [{"role": "user", "content": "Hello"}], - "n": 3, # Should fail validation - } - - response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload) - - if response.status_code == 422: - print("βœ… Validation correctly rejected n > 1") - print(response.json()) - else: - print(f"❌ Expected validation error, got: {response.status_code}") - - def test_streaming_with_parameters(): - """Test streaming response with unsupported parameters.""" - print("\n=== Testing Streaming with Unsupported Parameters ===") + """Test streaming response with Anthropic SSE format.""" + print("\n=== Testing Streaming with Parameters ===") payload = { "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "Write a short poem about programming"}], - "temperature": 0.9, # Will be warned about - "max_tokens": 200, # Will be warned about + "temperature": 0.9, + "max_tokens": MAX_TOKENS, "stream": True, } try: - response = requests.post(f"{BASE_URL}/v1/chat/completions", json=payload, stream=True) + response = requests.post(f"{BASE_URL}/v1/messages", json=payload, stream=True) if response.status_code == 200: print("βœ… Streaming request successful") print("First few chunks:") count = 0 + current_event = None for line in response.iter_lines(): if line and count < 5: line_str = line.decode("utf-8") - if line_str.startswith("data: ") and not line_str.endswith("[DONE]"): - print(f" {line_str}") + if line_str.startswith("event: "): + current_event = line_str[7:] + elif line_str.startswith("data: ") and current_event == "content_block_delta": + print(f" [{current_event}] {line_str}") count += 1 else: print(f"❌ Streaming request failed: {response.status_code}") @@ -173,7 +154,6 @@ def main(): test_basic_completion() test_with_claude_headers() test_compatibility_check() - test_parameter_validation() test_streaming_with_parameters() print("\n" + "=" * 50) diff --git a/tests/test_session_complete.py b/tests/test_session_complete.py index 425aeb4..41fe0b3 100644 --- a/tests/test_session_complete.py +++ b/tests/test_session_complete.py @@ -6,7 +6,7 @@ import pytest import requests -from tests.conftest import requires_server +from tests.conftest import requires_server, MAX_TOKENS import json import time @@ -33,10 +33,11 @@ def test_session_continuity_comprehensive(): print(f"\n{i}️⃣ Turn {i}: {turn['user']}") response = requests.post( - f"{BASE_URL}/v1/chat/completions", + f"{BASE_URL}/v1/messages", json={ "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": turn["user"]}], + "max_tokens": MAX_TOKENS, "session_id": session_id, }, ) @@ -46,7 +47,7 @@ def test_session_continuity_comprehensive(): return False result = response.json() - response_text = result["choices"][0]["message"]["content"] + response_text = result["content"][0]["text"] print(f" Response: {response_text[:100]}...") # Check if expected information is remembered @@ -86,25 +87,27 @@ def test_stateless_vs_session(): # Test stateless (no session_id) print("1️⃣ Stateless mode:") requests.post( - f"{BASE_URL}/v1/chat/completions", + f"{BASE_URL}/v1/messages", json={ "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "Remember: my favorite color is blue."}], + "max_tokens": MAX_TOKENS, }, ) # Follow up question without session_id response1 = requests.post( - f"{BASE_URL}/v1/chat/completions", + f"{BASE_URL}/v1/messages", json={ "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "What's my favorite color?"}], + "max_tokens": MAX_TOKENS, }, ) if response1.status_code == 200: result1 = response1.json() - stateless_response = result1["choices"][0]["message"]["content"] + stateless_response = result1["content"][0]["text"] print(f" Stateless response: {stateless_response[:100]}...") # Test session mode @@ -112,26 +115,28 @@ def test_stateless_vs_session(): session_id = "color-test-session" requests.post( - f"{BASE_URL}/v1/chat/completions", + f"{BASE_URL}/v1/messages", json={ "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "Remember: my favorite color is red."}], + "max_tokens": MAX_TOKENS, "session_id": session_id, }, ) response2 = requests.post( - f"{BASE_URL}/v1/chat/completions", + f"{BASE_URL}/v1/messages", json={ "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "What's my favorite color?"}], + "max_tokens": MAX_TOKENS, "session_id": session_id, }, ) if response2.status_code == 200: result2 = response2.json() - session_response = result2["choices"][0]["message"]["content"] + session_response = result2["content"][0]["text"] print(f" Session response: {session_response[:100]}...") if "red" in session_response.lower(): @@ -154,10 +159,11 @@ def test_session_endpoints(): for session_id in session_ids: requests.post( - f"{BASE_URL}/v1/chat/completions", + f"{BASE_URL}/v1/messages", json={ "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": f"Test session {session_id}"}], + "max_tokens": MAX_TOKENS, "session_id": session_id, }, ) diff --git a/tests/test_session_continuity.py b/tests/test_session_continuity.py index 26bb143..c6b5ea4 100644 --- a/tests/test_session_continuity.py +++ b/tests/test_session_continuity.py @@ -8,7 +8,7 @@ import pytest import requests -from tests.conftest import requires_server +from tests.conftest import requires_server, MAX_TOKENS import time from typing import Dict, Any @@ -23,17 +23,18 @@ def test_stateless_mode(): print("πŸ§ͺ Testing stateless mode...") response = requests.post( - f"{BASE_URL}/v1/chat/completions", + f"{BASE_URL}/v1/messages", json={ "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "Hello! My name is Alice."}], + "max_tokens": MAX_TOKENS, }, ) if response.status_code == 200: result = response.json() print(f"βœ… Stateless request successful") - print(f" Response: {result['choices'][0]['message']['content'][:100]}...") + print(f" Response: {result['content'][0]['text'][:100]}...") return True else: print(f"❌ Stateless request failed: {response.status_code} - {response.text}") @@ -48,10 +49,11 @@ def test_session_mode(): # First message in session print("1️⃣ First message in session...") response1 = requests.post( - f"{BASE_URL}/v1/chat/completions", + f"{BASE_URL}/v1/messages", json={ "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "Hello! My name is Bob. Remember this name."}], + "max_tokens": MAX_TOKENS, "session_id": TEST_SESSION_ID, }, ) @@ -62,15 +64,16 @@ def test_session_mode(): result1 = response1.json() print(f"βœ… First session message successful") - print(f" Response: {result1['choices'][0]['message']['content'][:100]}...") + print(f" Response: {result1['content'][0]['text'][:100]}...") # Second message in same session - should remember the name print("2️⃣ Second message in same session...") response2 = requests.post( - f"{BASE_URL}/v1/chat/completions", + f"{BASE_URL}/v1/messages", json={ "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "What's my name?"}], + "max_tokens": MAX_TOKENS, "session_id": TEST_SESSION_ID, }, ) @@ -81,10 +84,10 @@ def test_session_mode(): result2 = response2.json() print(f"βœ… Second session message successful") - print(f" Response: {result2['choices'][0]['message']['content'][:100]}...") + print(f" Response: {result2['content'][0]['text'][:100]}...") # Check if the response mentions the name "Bob" - response_text = result2["choices"][0]["message"]["content"].lower() + response_text = result2["content"][0]["text"].lower() if "bob" in response_text: print("βœ… Session continuity working - Claude remembered the name!") return True @@ -146,7 +149,7 @@ def test_session_streaming(): stream_session_id = "test-stream-456" response = requests.post( - f"{BASE_URL}/v1/chat/completions", + f"{BASE_URL}/v1/messages", json={ "model": "claude-3-5-sonnet-20241022", "messages": [ @@ -155,6 +158,7 @@ def test_session_streaming(): "content": "Hello! I'm testing streaming. My favorite color is purple.", } ], + "max_tokens": MAX_TOKENS, "session_id": stream_session_id, "stream": True, }, @@ -165,25 +169,33 @@ def test_session_streaming(): print(f"❌ Streaming request failed: {response.status_code}") return False + # Consume the stream + for line in response.iter_lines(): + if line: + line_str = line.decode("utf-8") + if line_str.startswith("event: message_stop"): + break + print("βœ… Streaming response received") # Follow up with another message in the same session time.sleep(1) # Give time for the session to be updated response2 = requests.post( - f"{BASE_URL}/v1/chat/completions", + f"{BASE_URL}/v1/messages", json={ "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "What's my favorite color?"}], + "max_tokens": MAX_TOKENS, "session_id": stream_session_id, }, ) if response2.status_code == 200: result = response2.json() - response_text = result["choices"][0]["message"]["content"].lower() + response_text = result["content"][0]["text"].lower() print(f"βœ… Follow-up message successful") - print(f" Response: {result['choices'][0]['message']['content'][:100]}...") + print(f" Response: {result['content'][0]['text'][:100]}...") if "purple" in response_text: print("βœ… Session continuity working with streaming!") diff --git a/tests/test_session_manager_unit.py b/tests/test_session_manager_unit.py index 961a385..47f88ee 100644 --- a/tests/test_session_manager_unit.py +++ b/tests/test_session_manager_unit.py @@ -7,7 +7,7 @@ """ import pytest -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock, patch import asyncio @@ -30,7 +30,7 @@ def test_session_creation_with_id(self): def test_session_expiry_in_future(self): """Newly created session expires in the future.""" session = Session(session_id="test-123") - assert session.expires_at > datetime.utcnow() + assert session.expires_at > datetime.now(timezone.utc) def test_touch_updates_last_accessed(self): """touch() updates last_accessed time.""" @@ -101,7 +101,7 @@ def test_is_expired_false_for_new_session(self): def test_is_expired_true_for_past_expiry(self): """Session with past expiry is expired.""" - session = Session(session_id="test-123", expires_at=datetime.utcnow() - timedelta(hours=1)) + session = Session(session_id="test-123", expires_at=datetime.now(timezone.utc) - timedelta(hours=1)) assert session.is_expired() is True def test_to_session_info_returns_correct_model(self): @@ -132,173 +132,190 @@ def test_manager_initialization(self, manager): assert manager.default_ttl_hours == 1 assert manager.cleanup_interval_minutes == 5 - def test_get_or_create_session_creates_new(self, manager): + @pytest.mark.asyncio + async def test_get_or_create_session_creates_new(self, manager): """get_or_create_session() creates new session if not exists.""" - session = manager.get_or_create_session("new-session") + session = await manager.get_or_create_session("new-session") assert session is not None assert session.session_id == "new-session" assert "new-session" in manager.sessions - def test_get_or_create_session_returns_existing(self, manager): + @pytest.mark.asyncio + async def test_get_or_create_session_returns_existing(self, manager): """get_or_create_session() returns existing session.""" - session1 = manager.get_or_create_session("existing") + session1 = await manager.get_or_create_session("existing") session1.add_messages([Message(role="user", content="Test")]) - session2 = manager.get_or_create_session("existing") + session2 = await manager.get_or_create_session("existing") assert session2 is session1 assert len(session2.messages) == 1 - def test_get_or_create_replaces_expired_session(self, manager): + @pytest.mark.asyncio + async def test_get_or_create_replaces_expired_session(self, manager): """get_or_create_session() replaces expired session with new one.""" # Create session and add messages first - session1 = manager.get_or_create_session("expiring") + session1 = await manager.get_or_create_session("expiring") session1.add_messages([Message(role="user", content="Old")]) # Expire AFTER adding messages (add_messages calls touch() which extends expiry) - session1.expires_at = datetime.utcnow() - timedelta(hours=1) + session1.expires_at = datetime.now(timezone.utc) - timedelta(hours=1) # Should get a new session since the old one is expired - session2 = manager.get_or_create_session("expiring") + session2 = await manager.get_or_create_session("expiring") assert len(session2.messages) == 0 # New session has no messages - def test_get_session_returns_none_for_nonexistent(self, manager): + @pytest.mark.asyncio + async def test_get_session_returns_none_for_nonexistent(self, manager): """get_session() returns None for non-existent session.""" - result = manager.get_session("nonexistent") + result = await manager.get_session("nonexistent") assert result is None - def test_get_session_returns_existing(self, manager): + @pytest.mark.asyncio + async def test_get_session_returns_existing(self, manager): """get_session() returns existing active session.""" - manager.get_or_create_session("existing") - result = manager.get_session("existing") + await manager.get_or_create_session("existing") + result = await manager.get_session("existing") assert result is not None assert result.session_id == "existing" - def test_get_session_returns_none_for_expired(self, manager): + @pytest.mark.asyncio + async def test_get_session_returns_none_for_expired(self, manager): """get_session() returns None and cleans up expired session.""" - session = manager.get_or_create_session("expiring") - session.expires_at = datetime.utcnow() - timedelta(hours=1) + session = await manager.get_or_create_session("expiring") + session.expires_at = datetime.now(timezone.utc) - timedelta(hours=1) - result = manager.get_session("expiring") + result = await manager.get_session("expiring") assert result is None assert "expiring" not in manager.sessions - def test_delete_session_removes_session(self, manager): + @pytest.mark.asyncio + async def test_delete_session_removes_session(self, manager): """delete_session() removes existing session.""" - manager.get_or_create_session("to-delete") + await manager.get_or_create_session("to-delete") assert "to-delete" in manager.sessions - result = manager.delete_session("to-delete") + result = await manager.delete_session("to-delete") assert result is True assert "to-delete" not in manager.sessions - def test_delete_session_returns_false_for_nonexistent(self, manager): + @pytest.mark.asyncio + async def test_delete_session_returns_false_for_nonexistent(self, manager): """delete_session() returns False for non-existent session.""" - result = manager.delete_session("nonexistent") + result = await manager.delete_session("nonexistent") assert result is False - def test_list_sessions_returns_active_sessions(self, manager): + @pytest.mark.asyncio + async def test_list_sessions_returns_active_sessions(self, manager): """list_sessions() returns list of active sessions.""" - manager.get_or_create_session("session-1") - manager.get_or_create_session("session-2") + await manager.get_or_create_session("session-1") + await manager.get_or_create_session("session-2") - sessions = manager.list_sessions() + sessions = await manager.list_sessions() assert len(sessions) == 2 session_ids = [s.session_id for s in sessions] assert "session-1" in session_ids assert "session-2" in session_ids - def test_list_sessions_excludes_expired(self, manager): + @pytest.mark.asyncio + async def test_list_sessions_excludes_expired(self, manager): """list_sessions() excludes and cleans up expired sessions.""" - manager.get_or_create_session("active") - expired = manager.get_or_create_session("expired") - expired.expires_at = datetime.utcnow() - timedelta(hours=1) + await manager.get_or_create_session("active") + expired = await manager.get_or_create_session("expired") + expired.expires_at = datetime.now(timezone.utc) - timedelta(hours=1) - sessions = manager.list_sessions() + sessions = await manager.list_sessions() assert len(sessions) == 1 assert sessions[0].session_id == "active" - def test_process_messages_stateless_mode(self, manager): + @pytest.mark.asyncio + async def test_process_messages_stateless_mode(self, manager): """process_messages() in stateless mode returns messages as-is.""" messages = [Message(role="user", content="Hello")] - result_msgs, session_id = manager.process_messages(messages, session_id=None) + result_msgs, session_id = await manager.process_messages(messages, session_id=None) assert result_msgs == messages assert session_id is None - def test_process_messages_session_mode(self, manager): - """process_messages() in session mode accumulates messages.""" + @pytest.mark.asyncio + async def test_process_messages_session_mode(self, manager): + """process_messages() in session mode replaces history with client-provided messages.""" msg1 = Message(role="user", content="First") msg2 = Message(role="user", content="Second") # First call - result1, sid1 = manager.process_messages([msg1], session_id="my-session") + result1, sid1 = await manager.process_messages([msg1], session_id="my-session") assert len(result1) == 1 assert sid1 == "my-session" - # Second call - should have both messages - result2, sid2 = manager.process_messages([msg2], session_id="my-session") + # Second call - client sends full history (both messages) + result2, sid2 = await manager.process_messages([msg1, msg2], session_id="my-session") assert len(result2) == 2 assert sid2 == "my-session" - def test_add_assistant_response_in_session_mode(self, manager): + @pytest.mark.asyncio + async def test_add_assistant_response_in_session_mode(self, manager): """add_assistant_response() adds response to session.""" - manager.get_or_create_session("my-session") + await manager.get_or_create_session("my-session") assistant_msg = Message(role="assistant", content="Hello!") - manager.add_assistant_response("my-session", assistant_msg) + await manager.add_assistant_response("my-session", assistant_msg) - session = manager.get_session("my-session") + session = await manager.get_session("my-session") assert len(session.messages) == 1 assert session.messages[0].role == "assistant" - def test_add_assistant_response_stateless_mode_noop(self, manager): + @pytest.mark.asyncio + async def test_add_assistant_response_stateless_mode_noop(self, manager): """add_assistant_response() does nothing in stateless mode.""" assistant_msg = Message(role="assistant", content="Hello!") # Should not raise, just do nothing - manager.add_assistant_response(None, assistant_msg) + await manager.add_assistant_response(None, assistant_msg) - def test_get_stats_returns_correct_counts(self, manager): + @pytest.mark.asyncio + async def test_get_stats_returns_correct_counts(self, manager): """get_stats() returns correct statistics.""" - manager.get_or_create_session("session-1") - session2 = manager.get_or_create_session("session-2") + await manager.get_or_create_session("session-1") + session2 = await manager.get_or_create_session("session-2") session2.add_messages([Message(role="user", content="Test")]) # Create expired session - expired = manager.get_or_create_session("expired") - expired.expires_at = datetime.utcnow() - timedelta(hours=1) + expired = await manager.get_or_create_session("expired") + expired.expires_at = datetime.now(timezone.utc) - timedelta(hours=1) - stats = manager.get_stats() + stats = await manager.get_stats() assert stats["active_sessions"] == 2 assert stats["expired_sessions"] == 1 assert stats["total_messages"] == 1 - def test_shutdown_clears_sessions(self, manager): + @pytest.mark.asyncio + async def test_shutdown_clears_sessions(self, manager): """shutdown() clears all sessions.""" - manager.get_or_create_session("session-1") - manager.get_or_create_session("session-2") + await manager.get_or_create_session("session-1") + await manager.get_or_create_session("session-2") assert len(manager.sessions) == 2 - manager.shutdown() + await manager.shutdown() assert len(manager.sessions) == 0 - def test_cleanup_expired_sessions(self, manager): + @pytest.mark.asyncio + async def test_cleanup_expired_sessions(self, manager): """_cleanup_expired_sessions() removes only expired sessions.""" - manager.get_or_create_session("active") - expired = manager.get_or_create_session("expired") - expired.expires_at = datetime.utcnow() - timedelta(hours=1) + await manager.get_or_create_session("active") + expired = await manager.get_or_create_session("expired") + expired.expires_at = datetime.now(timezone.utc) - timedelta(hours=1) - manager._cleanup_expired_sessions() + await manager._cleanup_expired_sessions() assert "active" in manager.sessions assert "expired" not in manager.sessions @@ -322,7 +339,7 @@ async def test_start_cleanup_task_creates_task(self, manager): assert manager._cleanup_task is not None # Clean up - manager.shutdown() + await manager.shutdown() @pytest.mark.asyncio async def test_start_cleanup_task_idempotent(self, manager): @@ -336,39 +353,32 @@ async def test_start_cleanup_task_idempotent(self, manager): assert first_task is second_task # Clean up - manager.shutdown() + await manager.shutdown() -class TestSessionManagerThreadSafety: - """Test thread safety of SessionManager operations.""" +class TestSessionManagerConcurrency: + """Test async concurrency safety of SessionManager operations.""" @pytest.fixture def manager(self): """Create a fresh SessionManager for each test.""" return SessionManager() - def test_concurrent_session_creation(self, manager): - """Multiple threads can create sessions concurrently.""" - import threading - + @pytest.mark.asyncio + async def test_concurrent_session_creation(self, manager): + """Multiple async tasks can create sessions concurrently.""" results = [] errors = [] - def create_session(session_id): + async def create_session(session_id): try: - session = manager.get_or_create_session(session_id) + session = await manager.get_or_create_session(session_id) results.append(session.session_id) except Exception as e: errors.append(str(e)) - threads = [] - for i in range(10): - t = threading.Thread(target=create_session, args=(f"session-{i}",)) - threads.append(t) - t.start() - - for t in threads: - t.join() + tasks = [create_session(f"session-{i}") for i in range(10)] + await asyncio.gather(*tasks) assert len(errors) == 0 assert len(results) == 10 diff --git a/tests/test_session_simple.py b/tests/test_session_simple.py index 0ddb224..73cbae0 100644 --- a/tests/test_session_simple.py +++ b/tests/test_session_simple.py @@ -10,7 +10,7 @@ import json import time -from tests.conftest import requires_server +from tests.conftest import requires_server, MAX_TOKENS BASE_URL = "http://localhost:8000" TEST_SESSION_ID = "test-simple-session" @@ -23,10 +23,11 @@ def test_session_creation(): # Make a request with a session_id response = requests.post( - f"{BASE_URL}/v1/chat/completions", + f"{BASE_URL}/v1/messages", json={ "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "Hello, remember my name is Alice."}], + "max_tokens": MAX_TOKENS, "session_id": TEST_SESSION_ID, }, ) @@ -63,10 +64,11 @@ def test_session_continuity(): # Follow up message asking about the name response = requests.post( - f"{BASE_URL}/v1/chat/completions", + f"{BASE_URL}/v1/messages", json={ "model": "claude-3-5-sonnet-20241022", "messages": [{"role": "user", "content": "What's my name?"}], + "max_tokens": MAX_TOKENS, "session_id": TEST_SESSION_ID, }, ) @@ -76,8 +78,8 @@ def test_session_continuity(): return False result = response.json() - response_text = result["choices"][0]["message"]["content"].lower() - print(f"Response: {result['choices'][0]['message']['content'][:100]}...") + response_text = result["content"][0]["text"].lower() + print(f"Response: {result['content'][0]['text'][:100]}...") # Check if response mentions Alice if "alice" in response_text: diff --git a/tests/test_textblock_fix.py b/tests/test_textblock_fix.py index 69fc7db..d733169 100644 --- a/tests/test_textblock_fix.py +++ b/tests/test_textblock_fix.py @@ -19,6 +19,7 @@ def test_textblock_fix(): request_data = { "model": "claude-3-7-sonnet-20250219", "messages": [{"role": "user", "content": "Hello! Can you briefly introduce yourself?"}], + "max_tokens": 4096, "stream": True, "temperature": 0.0, } @@ -26,7 +27,7 @@ def test_textblock_fix(): try: # Send streaming request response = requests.post( - "http://localhost:8000/v1/chat/completions", json=request_data, stream=True, timeout=30 + "http://localhost:8000/v1/messages", json=request_data, stream=True, timeout=30 ) print(f"βœ… Response status: {response.status_code}") @@ -35,46 +36,44 @@ def test_textblock_fix(): print(f"❌ Request failed: {response.text}") return False - # Parse streaming chunks and collect content + # Parse Anthropic SSE streaming chunks and collect content all_content = "" - has_role_chunk = False + has_content_block_start = False has_content = False + current_event = None for line in response.iter_lines(): if line: line_str = line.decode("utf-8") - if line_str.startswith("data: "): - data_str = line_str[6:] # Remove "data: " prefix - - if data_str == "[DONE]": - break + if line_str.startswith("event: "): + current_event = line_str[7:] + if current_event == "content_block_start": + has_content_block_start = True + print(f"βœ… Found content_block_start event") + elif line_str.startswith("data: "): + data_str = line_str[6:] try: chunk_data = json.loads(data_str) - # Check chunk structure - if "choices" in chunk_data and len(chunk_data["choices"]) > 0: - choice = chunk_data["choices"][0] - delta = choice.get("delta", {}) - - # Check for role chunk - if "role" in delta: - has_role_chunk = True - print(f"βœ… Found role chunk") - - # Check for content chunk - if "content" in delta: - content = delta["content"] - all_content += content + if current_event == "content_block_delta": + delta = chunk_data.get("delta", {}) + if delta.get("type") == "text_delta": + text = delta.get("text", "") + all_content += text has_content = True - print(f"βœ… Found content: {content[:50]}...") + if len(all_content) <= 50: + print(f"βœ… Found content: {text[:50]}...") + + elif current_event == "message_stop": + break except json.JSONDecodeError as e: print(f"❌ Invalid JSON in chunk: {data_str}") return False print(f"\nπŸ“Š Test Results:") - print(f" Has role chunk: {has_role_chunk}") + print(f" Has content_block_start: {has_content_block_start}") print(f" Has content: {has_content}") print(f" Total content length: {len(all_content)}") print(f" Content preview: {all_content[:200]}...") diff --git a/tests/test_tool_execution.py b/tests/test_tool_execution.py index 3c8fe34..4e48d7d 100644 --- a/tests/test_tool_execution.py +++ b/tests/test_tool_execution.py @@ -9,6 +9,8 @@ """ import pytest +import tempfile +from unittest.mock import patch from claude_agent_sdk import ClaudeAgentOptions @@ -138,20 +140,175 @@ def test_handles_dict_content_blocks(self): class TestClaudeCliPermissionMode: - """Test that ClaudeCodeCLI passes permission_mode correctly.""" + """Test that ClaudeCodeCLI passes permission_mode via the generic claude_options mechanism.""" - def test_run_completion_accepts_permission_mode(self): - """Test that run_completion method accepts permission_mode parameter.""" + @pytest.fixture + def cli_instance(self): + """Create a CLI instance with mocked auth (no live SDK).""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch("src.auth.validate_claude_code_auth") as mock_validate: + with patch("src.auth.auth_manager") as mock_auth: + mock_validate.return_value = (True, {"method": "anthropic"}) + mock_auth.get_claude_code_env_vars.return_value = {} + + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI(cwd=temp_dir) + yield cli + + def test_run_completion_accepts_claude_options(self): + """run_completion exposes a generic claude_options dict, not an explicit permission_mode param.""" from src.claude_cli import ClaudeCodeCLI import inspect - # Check that permission_mode is in the method signature sig = inspect.signature(ClaudeCodeCLI.run_completion) - param_names = list(sig.parameters.keys()) - assert ( - "permission_mode" in param_names - ), "run_completion should accept permission_mode parameter" + assert "claude_options" in sig.parameters, ( + "run_completion should accept a generic claude_options dict" + ) + + @pytest.mark.asyncio + async def test_permission_mode_flows_to_claude_agent_options(self, cli_instance): + """permission_mode supplied via claude_options reaches ClaudeAgentOptions.""" + captured_options = [] + + async def mock_query(prompt, options): + captured_options.append(options) + yield {"type": "assistant"} + + with patch("src.claude_cli.query", mock_query): + async for _ in cli_instance.run_completion( + "Hello", claude_options={"permission_mode": "acceptEdits"} + ): + pass + + assert len(captured_options) == 1 + assert captured_options[0].permission_mode == "acceptEdits" + + @pytest.mark.asyncio + async def test_cli_path_reaches_claude_agent_options(self, cli_instance): + """The wrapper pins the SDK to the configured local Claude Code binary.""" + from src.constants import CLAUDE_CLI_PATH + + captured_options = [] + + async def mock_query(prompt, options): + captured_options.append(options) + yield {"type": "assistant"} + + with patch("src.claude_cli.query", mock_query): + async for _ in cli_instance.run_completion("Hello"): + pass + + assert len(captured_options) == 1 + assert captured_options[0].cli_path == CLAUDE_CLI_PATH + + +class TestSystemPromptRouting: + """System-prompt selection by model type (claude_code preset vs neutral).""" + + @pytest.fixture + def cli_instance(self): + with tempfile.TemporaryDirectory() as temp_dir: + with patch("src.auth.validate_claude_code_auth") as mock_validate: + with patch("src.auth.auth_manager") as mock_auth: + mock_validate.return_value = (True, {"method": "anthropic"}) + mock_auth.get_claude_code_env_vars.return_value = {} + + from src.claude_cli import ClaudeCodeCLI + + cli = ClaudeCodeCLI(cwd=temp_dir) + yield cli + + @staticmethod + async def _capture(cli, **kwargs): + captured = [] + + async def mock_query(prompt, options): + captured.append(options) + yield {"type": "assistant"} + + with patch("src.claude_cli.query", mock_query): + async for _ in cli.run_completion("Hello", **kwargs): + pass + return captured + + @pytest.mark.asyncio + async def test_passthrough_model_gets_neutral_prompt(self, cli_instance): + """A passthrough model (glm-5.2) gets the neutral prompt as a plain str.""" + from src.claude_cli import NEUTRAL_SYSTEM_PROMPT + + captured = await self._capture(cli_instance, claude_options={"model": "glm-5.2"}) + + assert len(captured) == 1 + # Must be a plain str, NOT a dict: the SDK only emits --system-prompt for + # str (and file/append-preset dict shapes). A {"type":"text",...} dict is + # ignored and silently falls back to the CLI default (system-prompt bloat). + assert captured[0].system_prompt == NEUTRAL_SYSTEM_PROMPT + + @pytest.mark.asyncio + async def test_claude_model_keeps_claude_code_preset(self, cli_instance): + """A Claude model with no system prompt keeps the claude_code preset.""" + captured = await self._capture( + cli_instance, claude_options={"model": "claude-sonnet-4-6"} + ) + + assert len(captured) == 1 + assert captured[0].system_prompt == {"type": "preset", "preset": "claude_code"} + + @pytest.mark.asyncio + async def test_explicit_system_prompt_wins(self, cli_instance): + """A caller-supplied system_prompt overrides model-based routing (as str).""" + captured = await self._capture( + cli_instance, + system_prompt="Answer in haiku only.", + claude_options={"model": "glm-5.2"}, + ) + + assert len(captured) == 1 + assert captured[0].system_prompt == "Answer in haiku only." + + +class TestSystemPromptFlagContract: + """Lock the SDK CLI flag contract we depend on for system-prompt bloat. + + The SDK's subprocess flag builder emits --system-prompt only for a plain str + (or --system-prompt-file / --append-system-preset for those specific dict + shapes). Any other dict β€” including {"type":"text",...} β€” emits no flag and + silently falls back to the CLI default (the full claude_code prompt). These + tests guard against re-introducing the dict form. + """ + + @staticmethod + def _cmd(system_prompt, **kwargs): + from claude_agent_sdk import ClaudeAgentOptions + from claude_agent_sdk._internal.transport.subprocess_cli import ( + SubprocessCLITransport, + ) + + opts = ClaudeAgentOptions( + cli_path="/usr/local/bin/claude", system_prompt=system_prompt, **kwargs + ) + return SubprocessCLITransport("hi", opts)._build_command() + + def test_str_system_prompt_emits_flag(self): + cmd = self._cmd("You are a helpful assistant.") + i = cmd.index("--system-prompt") + assert cmd[i + 1] == "You are a helpful assistant." + + def test_text_dict_is_ignored_by_sdk(self): + # Documents the bug we fixed: {"type":"text",...} produces NO flag. + cmd = self._cmd({"type": "text", "text": "You are a helpful assistant."}) + assert "--system-prompt" not in cmd + + def test_empty_tools_emits_empty_tools_flag(self): + cmd = self._cmd("x", tools=[]) + i = cmd.index("--tools") + assert cmd[i + 1] == "" + + def test_empty_setting_sources_emits_flag(self): + cmd = self._cmd("x", setting_sources=[]) + assert any(f.startswith("--setting-sources") for f in cmd) if __name__ == "__main__":