initial commits

This commit is contained in:
2026-06-26 17:02:21 +08:00
commit 2851fa01cf
28 changed files with 2411 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
.venv/
.uv-cache/
.pytest_cache/
.test-tmp/
pytest-cache-files-*/
__pycache__/
*.py[cod]
*$py.class
.env
.env.*
!.env.example
nexus-claude-api.local.json

52
README.md Normal file
View File

@@ -0,0 +1,52 @@
# nexus-claude-api
Local Anthropic-compatible API proxy for using AI Nexus Claude models with Claude Code.
AI Nexus currently documents AWS Bedrock Converse API as the workaround while Anthropic Messages API support is unavailable. This service exposes local Anthropic-compatible endpoints and translates them to Nexus Converse requests.
## Quick Start
```powershell
cd nexus-claude-api
uv sync --extra dev
uv run nexus-claude-api start --port 4141 --claude-code
```
Use the printed Claude Code command in the same shell.
`ANTHROPIC_AUTH_TOKEN='dummy'` in the printed command is only a local Claude Code compatibility placeholder. Claude Code expects an Anthropic auth token variable to exist, but this local proxy does not validate it by default. It is not your Nexus key.
## Endpoints
- `GET /`
- `GET /health`
- `GET /v1/models`
- `POST /v1/messages`
- `POST /v1/messages/count_tokens`
## Environment
Credential lookup order:
1. `--api-key`
2. local `nexus-claude-api.local.json`
3. `NEXUS_API_KEY`
4. `AWS_BEARER_TOKEN_BEDROCK`
For local hardcoded configuration, create `nexus-claude-api.local.json`:
```json
{
"api_key": "your-nexus-api-key"
}
```
This file is ignored by git.
The service binds to `127.0.0.1` by default and does not persist API keys.
## Docs
- [PRD](docs/PRD.md)
- [Requirements Design](docs/REQUIREMENTS_DESIGN.md)
- [AI Nexus Claude Documentation](docs/AI_NEXUS_CLAUDE.md)

243
docs/AI_NEXUS_CLAUDE.md Normal file
View File

@@ -0,0 +1,243 @@
# AI Nexus Claude Documentation
Source: `AI Nexus Product Documentation _ Models _ Anthropic _ Claude _ One Developer Portal.pdf`
Extracted locally on 2026-06-26.
## Important Notice
AI Nexus temporarily does not support the Anthropic Messages API. Users are asked to wait for updates because providers had to be changed on short notice due to governance regulations. AI Nexus is actively working on enabling the Messages API.
Anthropic may restrict access to services by region. Users or their organizational units must verify whether country-specific access is permitted before using the service.
## Overview
Claude is a family of AI assistants created by Anthropic. It is designed to be helpful, honest, and safe in conversations. Claude can answer questions, write and edit text, summarize documents, and help with coding in a natural chat-like way.
Claude models are available in different sizes:
- A fast lightweight model for simple tasks.
- A balanced model for everyday use.
- A more powerful model for complex reasoning and analysis.
Claude is used in chatbots, research tools, and workplace assistants where reliability and clear, thoughtful responses matter. Claude models are often strong choices for coding tasks.
## Access
Refer to the internal "How to get access to the models" documentation to get access.
The documented endpoint is:
```text
https://genai-nexus.api.corpinter.net
```
The service uses AWS Bedrock Runtime with an internal Nexus endpoint.
## Available Models
| Model | Production |
| --- | --- |
| `claude-sonnet-4.6` | Yes |
| `claude-opus-4.6` | Yes |
| `claude-haiku-4.5` | Yes |
AI Nexus recommends `claude-sonnet-4.6` as the cost-effective default. It largely matches or exceeds `claude-opus-4.6` on most benchmarks at lower cost. Use Opus only when the use case specifically requires Opus-level capabilities.
## Converse API Workaround
AI Nexus uses Anthropic models provided by AWS Bedrock. Because the Anthropic Messages API is not currently supported, AI Nexus recommends using the AWS Converse API.
### Python Text Generation
```python
import boto3
import os
# export AWS_BEARER_TOKEN_BEDROCK=${your-bedrock-api-key}
os.environ["AWS_BEARER_TOKEN_BEDROCK"] = "<nexus-api-key>"
client = boto3.client(
service_name="bedrock-runtime",
endpoint_url="https://genai-nexus.api.corpinter.net",
region_name="nexus", # required but internally overridden
)
response = client.converse(
modelId="claude-sonnet-4",
messages=[{"role": "user", "content": [{"text": "Hello"}]}],
)
print(response["output"]["message"]["content"][0]["text"])
```
### HTTP Text Generation
```bash
curl https://genai-nexus.api.corpinter.net/model/<model-id>/converse \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $NEXUS_API_KEY" \
-d '{
"model": "claude-sonnet-4",
"messages": [
{
"role": "user",
"content": [{"text": "Hello, Claude"}]
}
]
}'
```
## Streaming Responses
Streaming returns partial output as soon as tokens or text deltas are produced. This lowers latency to first character and is useful for chat UIs, live drafting, assistants, and long answers.
Core event types:
- `contentBlockDelta`: contains a delta, usually `delta.text`, with newly generated text.
- `messageStop`: signals the end of generation. Inspect `stopReason` if needed.
- `contentBlockStart` / `contentBlockStop`: structural boundaries that can appear with tool use or multimodal output.
- `metadata`: optional interim metadata such as token counts.
- `error`: error event. The caller should abort the current display and handle retry or logging.
### Python Streaming
```python
import boto3
import os
# export AWS_BEARER_TOKEN_BEDROCK=${your-bedrock-api-key}
os.environ["AWS_BEARER_TOKEN_BEDROCK"] = "<nexus-api-key>"
client = boto3.client(
service_name="bedrock-runtime",
endpoint_url="https://genai-nexus.api.corpinter.net",
region_name="nexus", # required but internally overridden
)
response = client.converse_stream(
modelId="claude-sonnet-4",
messages=[
{
"role": "user",
"content": [{"text": "What is the meaning of life?"}],
}
],
)
stream = response.get("stream")
collected = []
for event in stream:
if "contentBlockDelta" in event:
delta = event["contentBlockDelta"]["delta"]
text = delta.get("text")
if text:
collected.append(text)
print(text, end="", flush=True)
if "messageStop" in event:
break
```
### HTTP Streaming
Raw HTTP streaming uses chunked transfer. This request initiates streaming generation and receives incremental chunks:
```bash
curl -N \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $NEXUS_API_KEY" \
https://genai-nexus.api.corpinter.net/model/<model-id>/converse-stream \
-d '{
"model": "claude-sonnet-4",
"messages": [
{"role": "user", "content": [{"text": "Say hello"}]}
]
}'
```
Simplified request schema:
```http
POST /model/<model-id>/converse-stream HTTP/1.1
Content-Type: application/json
```
## Image Understanding
Images are passed through the `messages` parameter using an image content block.
### HTTP Image Example
```bash
# Base64 encode the image into IMG_B64.
# Use raw bytes, not a data URI.
IMG_B64=$(base64 -i ./image.png | tr -d '\n')
curl \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $NEXUS_API_KEY" \
https://genai-nexus.api.corpinter.net/model/<model-id>/converse \
-d '{
"model": "claude-sonnet-4",
"messages": [
{
"role": "user",
"content": [
{"text": "Describe the image"},
{
"image": {
"format": "png",
"source": {"bytes": "'$IMG_B64'"}
}
}
]
}
]
}'
```
### Python Image Example
```python
image_ext = image_filepath.split(".")[-1]
with open(image_filepath, "rb") as f:
image = f.read()
messages = [
{
"role": "user",
"content": [
{"text": "Describe the image"},
{
"image": {
"format": image_ext,
"source": {
"bytes": image,
},
}
},
],
}
]
```
## Implications for `nexus-claude-api`
Claude Code expects Anthropic Messages API behavior, while AI Nexus currently documents Converse API behavior. The local proxy should therefore:
- Expose an Anthropic-compatible `/v1/messages` endpoint locally.
- Translate Anthropic Messages requests to Bedrock Converse requests.
- Translate Bedrock Converse and Converse Stream responses back to Anthropic-compatible responses.
- Use `AWS_BEARER_TOKEN_BEDROCK` or `NEXUS_API_KEY` as the outbound Nexus credential.
- Avoid changing Claude Code workflows or requiring users to call Converse directly.
Claude Code expects Anthropic Messages API behavior, while AI Nexus currently documents Converse API behavior. The local proxy should therefore:
- Expose an Anthropic-compatible `/v1/messages` endpoint locally.
- Translate Anthropic Messages requests to Bedrock Converse requests.
- Translate Bedrock Converse and Converse Stream responses back to Anthropic-compatible responses.
- Use `AWS_BEARER_TOKEN_BEDROCK` or `NEXUS_API_KEY` as the outbound Nexus credential.
- Avoid changing Claude Code workflows or requiring users to call Converse directly.

86
docs/PRD.md Normal file
View File

@@ -0,0 +1,86 @@
# nexus-claude-api PRD
## Product Overview
`nexus-claude-api` is a local Python API proxy that lets Claude Code use company-approved Claude models through AI Nexus.
AI Nexus currently documents that the Anthropic Messages API is temporarily unsupported and recommends AWS Bedrock Converse API as the workaround. Claude Code expects an Anthropic-compatible Messages API. This project bridges that gap locally.
Reference: [AI_NEXUS_CLAUDE.md](AI_NEXUS_CLAUDE.md)
## Users
Target users are internal developers who:
- Have an AI Nexus API key.
- Want to use Claude Code with company-approved Claude models.
- Work primarily in local development environments.
- Need Claude Code workflows such as code editing, tool use, and streaming responses.
## Goals
- Provide a local Anthropic-compatible API for Claude Code.
- Start locally with a command similar to `copilot-api`.
- Convert Anthropic Messages requests to AI Nexus Bedrock Converse requests.
- Convert Nexus responses back to Anthropic Messages responses.
- Support text, streaming, tools, tool results, and image inputs.
- Provide model discovery and token-count compatibility endpoints.
- Keep credentials local and avoid logging secrets.
## Non-goals
The first version will not include:
- OpenAI-compatible `/v1/chat/completions`.
- A dashboard.
- Nexus API key provisioning.
- Multi-user hosted gateway deployment.
- Database persistence.
- Direct Anthropic public API forwarding.
## Supported Models
The local API exposes:
- `claude-sonnet-4.6`
- `claude-opus-4.6`
- `claude-haiku-4.5`
Defaults:
- Main model: `claude-sonnet-4.6`
- Small model: `claude-haiku-4.5`
`claude-sonnet-4.6` is the default because the AI Nexus documentation recommends it as the cost-effective default for most use cases.
## User Stories
- As a developer, I can store my Nexus key in ignored local config or set `NEXUS_API_KEY`, then run `nexus-claude-api start --claude-code` to get a Claude Code launch command.
- As a Claude Code user, I can run coding workflows through local `http://127.0.0.1:4141`.
- As a Claude Code user, I can receive streaming model output.
- As a Claude Code user, I can use tool calls and tool results.
- As a multimodal user, I can send images through Claude-compatible image content blocks.
- As a developer debugging setup, I can enable verbose logs without exposing tokens.
## Acceptance Criteria
- `uv run nexus-claude-api start --port 4141 --claude-code` starts a local server.
- The server binds to `127.0.0.1` by default.
- Missing Nexus credentials fail fast with a clear error.
- `GET /health` returns healthy status.
- `GET /v1/models` returns the supported Claude models.
- `POST /v1/messages` works for non-streaming text generation.
- `POST /v1/messages` works for streaming text generation.
- Tool use and tool result conversion are covered by tests.
- Image block conversion is covered by tests.
- `POST /v1/messages/count_tokens` returns an Anthropic-compatible token count response.
- Claude Code can be launched with the printed environment command.
## Security Requirements
- Do not persist API keys automatically.
- If the user chooses hardcoded local configuration, keep it in ignored `nexus-claude-api.local.json`.
- Do not print or log API keys.
- Redact authorization headers in debug logs.
- Bind locally by default.
- Allow `0.0.0.0` only when explicitly requested.

217
docs/REQUIREMENTS_DESIGN.md Normal file
View File

@@ -0,0 +1,217 @@
# nexus-claude-api Requirements Design
## Technical Stack
- Python `>=3.11`
- Package manager: `uv`
- Web framework: FastAPI
- ASGI server: Uvicorn
- Nexus client: boto3 Bedrock Runtime client
- Validation: Pydantic
- CLI: standard library `argparse`
- Tests: pytest
All project dependencies must be managed by `uv` and the project virtual environment. Do not install global Python dependencies.
## Project Structure
```text
nexus-claude-api/
pyproject.toml
README.md
docs/
AI_NEXUS_CLAUDE.md
PRD.md
REQUIREMENTS_DESIGN.md
src/
nexus_claude_api/
__init__.py
__main__.py
cli.py
config.py
errors.py
models.py
nexus_client.py
server.py
shell.py
tokens.py
routes/
health.py
messages.py
models.py
translators/
anthropic_to_bedrock.py
bedrock_to_anthropic.py
stream.py
tests/
```
## CLI Contract
Primary command:
```powershell
uv run nexus-claude-api start --port 4141 --claude-code
```
Options:
- `--host`: default `127.0.0.1`
- `--port`, `-p`: default `4141`
- `--endpoint-url`: default `https://genai-nexus.api.corpinter.net`
- `--api-key`: optional; fallback to ignored local config, `NEXUS_API_KEY`, then `AWS_BEARER_TOKEN_BEDROCK`
- `--model`: default `claude-sonnet-4.6`
- `--small-model`: default `claude-haiku-4.5`
- `--claude-code`: print Claude Code launch command
- `--verbose`, `-v`: debug logging without secrets
When `--claude-code` is used, print a PowerShell command that sets:
- `ANTHROPIC_BASE_URL`
- `ANTHROPIC_AUTH_TOKEN`
- `ANTHROPIC_MODEL`
- `ANTHROPIC_DEFAULT_SONNET_MODEL`
- `ANTHROPIC_DEFAULT_OPUS_MODEL`
- `ANTHROPIC_SMALL_FAST_MODEL`
- `ANTHROPIC_DEFAULT_HAIKU_MODEL`
- `DISABLE_NON_ESSENTIAL_MODEL_CALLS`
- `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`
## HTTP API Contract
Expose:
- `GET /`
- `GET /health`
- `GET /v1/models`
- `POST /v1/messages`
- `POST /v1/messages/count_tokens`
`ANTHROPIC_AUTH_TOKEN` is printed as `dummy` because Claude Code expects an Anthropic auth token variable to exist. This local proxy does not validate that inbound token by default. It is not the Nexus key.
Inbound authentication headers are accepted for compatibility but not validated by default because the service is local. Outbound Nexus authentication uses `--api-key`, ignored local `nexus-claude-api.local.json`, `NEXUS_API_KEY`, or `AWS_BEARER_TOKEN_BEDROCK`.
## Model Mapping
Public local model IDs:
- `claude-sonnet-4.6`
- `claude-opus-4.6`
- `claude-haiku-4.5`
Backend IDs are resolved through a mapping table. The initial default mapping keeps the same IDs, except common short aliases are supported:
- `claude-sonnet-4` -> `claude-sonnet-4.6`
- `claude-opus-4` -> `claude-opus-4.6`
- `claude-haiku-4` -> `claude-haiku-4.5`
If Nexus requires different backend IDs, update the mapping without changing Claude Code-facing model IDs.
## Request Translation
Anthropic to Bedrock:
- `model` -> `modelId`
- `messages[].role` -> `role`
- string content -> `{ "text": "..." }`
- `{ "type": "text", "text": "..." }` -> `{ "text": "..." }`
- Anthropic image block -> Bedrock image block
- assistant `tool_use` -> Bedrock `toolUse`
- user `tool_result` -> Bedrock `toolResult`
- `system` -> Bedrock `system`
- `max_tokens`, `temperature`, `top_p` -> `inferenceConfig`
- `stop_sequences` -> `stopSequences`
- `tools` and `tool_choice` -> `toolConfig`
Unsupported content blocks should return `400 invalid_request_error`.
## Response Translation
Bedrock to Anthropic:
- Bedrock text content -> Anthropic text block.
- Bedrock `toolUse` -> Anthropic `tool_use`.
- Bedrock usage -> Anthropic `usage`.
- Bedrock stop reason maps to Anthropic stop reason.
Stop reason mapping:
- `end_turn` -> `end_turn`
- `max_tokens` -> `max_tokens`
- `stop_sequence` -> `stop_sequence`
- `tool_use` -> `tool_use`
- unknown -> `end_turn`
## Streaming Translation
Use `converse_stream`.
Translate Bedrock stream events to Anthropic SSE events:
- `messageStart` -> `message_start`
- `contentBlockStart` -> `content_block_start`
- `contentBlockDelta.delta.text` -> `content_block_delta` with `text_delta`
- tool input deltas -> `content_block_delta` with `input_json_delta`
- `contentBlockStop` -> `content_block_stop`
- `messageStop` -> `message_delta`, then `message_stop`
- `metadata.usage` -> usage update on final `message_delta`
- backend error -> `error`
SSE frame format:
```text
event: <event_type>
data: <json>
```
## Error Handling
Return Anthropic-compatible errors:
```json
{
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "..."
}
}
```
Status mapping:
- invalid request: `400`
- missing local Nexus credential: startup failure
- Nexus auth failure: `401` or `403`
- Nexus throttling: `429`
- Nexus network/timeout: `502` or `504`
- unexpected server error: `500`
## Testing
Unit tests:
- Minimal Anthropic text request -> Bedrock payload.
- System prompt conversion.
- Image block conversion.
- Tool config conversion.
- Tool use and tool result conversion.
- Bedrock text response -> Anthropic response.
- Bedrock tool response -> Anthropic tool response.
- Bedrock streaming events -> Anthropic SSE sequence.
- Token counting approximation.
Route tests:
- `GET /health`
- `GET /v1/models`
- `POST /v1/messages` non-stream
- `POST /v1/messages` stream
- `POST /v1/messages/count_tokens`
CLI tests:
- `nexus-claude-api --help`
- Claude Code command generation.
- Missing API key validation.

33
pyproject.toml Normal file
View File

@@ -0,0 +1,33 @@
[project]
name = "nexus-claude-api"
version = "0.1.0"
description = "Local Anthropic-compatible Claude Code proxy for AI Nexus Bedrock Converse."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"boto3>=1.34.0",
"fastapi>=0.115.0",
"pydantic>=2.8.0",
"uvicorn>=0.30.0",
]
[project.optional-dependencies]
dev = [
"httpx>=0.27.0",
"pytest>=8.0.0",
]
[project.scripts]
nexus-claude-api = "nexus_claude_api.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/nexus_claude_api"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]

View File

@@ -0,0 +1,4 @@
"""Local Anthropic-compatible proxy for AI Nexus Claude models."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,6 @@
from nexus_claude_api.cli import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
import argparse
import sys
import uvicorn
from nexus_claude_api.config import (
DEFAULT_ENDPOINT_URL,
DEFAULT_HOST,
DEFAULT_MODEL,
DEFAULT_PORT,
DEFAULT_SMALL_MODEL,
Settings,
)
from nexus_claude_api.server import create_app
from nexus_claude_api.shell import generate_claude_code_powershell
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="nexus-claude-api")
subparsers = parser.add_subparsers(dest="command")
start = subparsers.add_parser("start", help="Start the local API server")
start.add_argument("--host", default=DEFAULT_HOST)
start.add_argument("--port", "-p", type=int, default=DEFAULT_PORT)
start.add_argument("--endpoint-url", default=DEFAULT_ENDPOINT_URL)
start.add_argument("--api-key")
start.add_argument("--model", default=DEFAULT_MODEL)
start.add_argument("--small-model", default=DEFAULT_SMALL_MODEL)
start.add_argument("--claude-code", action="store_true")
start.add_argument("--verbose", "-v", action="store_true")
start.add_argument(
"--dry-run",
action="store_true",
help="Validate config and print helper output without starting the server.",
)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "start":
return run_start(args)
parser.print_help()
return 0
def run_start(args: argparse.Namespace) -> int:
settings = Settings.from_values(
host=args.host,
port=args.port,
endpoint_url=args.endpoint_url,
api_key=args.api_key,
model=args.model,
small_model=args.small_model,
verbose=args.verbose,
)
if not settings.api_key:
print(
"Missing Nexus API key. Add nexus-claude-api.local.json, set "
"NEXUS_API_KEY or AWS_BEARER_TOKEN_BEDROCK, or pass --api-key.",
file=sys.stderr,
)
return 2
if args.claude_code:
print("Claude Code command:")
print(generate_claude_code_powershell(settings))
if args.dry_run:
return 0
app = create_app(settings)
uvicorn.run(app, host=settings.host, port=settings.port, log_level="debug" if settings.verbose else "info")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
DEFAULT_ENDPOINT_URL = "https://genai-nexus.api.corpinter.net"
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 4141
DEFAULT_MODEL = "claude-sonnet-4.6"
DEFAULT_SMALL_MODEL = "claude-haiku-4.5"
DEFAULT_OPUS_MODEL = "claude-opus-4.6"
LOCAL_CONFIG_FILENAME = "nexus-claude-api.local.json"
MODEL_ID_MAP = {
"claude-sonnet-4.6": "claude-sonnet-4.6",
"claude-opus-4.6": "claude-opus-4.6",
"claude-haiku-4.5": "claude-haiku-4.5",
"claude-sonnet-4": "claude-sonnet-4.6",
"claude-opus-4": "claude-opus-4.6",
"claude-haiku-4": "claude-haiku-4.5",
}
@dataclass(frozen=True)
class Settings:
host: str = DEFAULT_HOST
port: int = DEFAULT_PORT
endpoint_url: str = DEFAULT_ENDPOINT_URL
api_key: str | None = None
model: str = DEFAULT_MODEL
small_model: str = DEFAULT_SMALL_MODEL
verbose: bool = False
require_api_key: bool = True
@classmethod
def from_values(
cls,
*,
host: str = DEFAULT_HOST,
port: int = DEFAULT_PORT,
endpoint_url: str = DEFAULT_ENDPOINT_URL,
api_key: str | None = None,
model: str = DEFAULT_MODEL,
small_model: str = DEFAULT_SMALL_MODEL,
verbose: bool = False,
require_api_key: bool = True,
) -> "Settings":
local_config = load_local_config()
resolved_api_key = (
api_key
or local_config.get("api_key")
or local_config.get("nexus_api_key")
or os.environ.get("NEXUS_API_KEY")
or os.environ.get("AWS_BEARER_TOKEN_BEDROCK")
)
return cls(
host=host,
port=port,
endpoint_url=endpoint_url,
api_key=resolved_api_key,
model=model,
small_model=small_model,
verbose=verbose,
require_api_key=require_api_key,
)
@property
def base_url(self) -> str:
return f"http://{self.host}:{self.port}"
def resolve_backend_model(model: str) -> str:
return MODEL_ID_MAP.get(model, model)
def load_local_config(path: str | Path = LOCAL_CONFIG_FILENAME) -> dict[str, str]:
config_path = Path(path)
if not config_path.exists():
return {}
try:
raw = json.loads(config_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in {config_path}") from exc
if not isinstance(raw, dict):
raise ValueError(f"{config_path} must contain a JSON object")
return {str(key): str(value) for key, value in raw.items() if value is not None}

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
from fastapi import HTTPException
from fastapi.responses import JSONResponse
class NexusClaudeError(Exception):
def __init__(
self,
message: str,
*,
status_code: int = 400,
error_type: str = "invalid_request_error",
) -> None:
super().__init__(message)
self.message = message
self.status_code = status_code
self.error_type = error_type
def anthropic_error_response(
message: str,
*,
status_code: int = 400,
error_type: str = "invalid_request_error",
) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content={
"type": "error",
"error": {
"type": error_type,
"message": message,
},
},
)
def map_http_exception(exc: HTTPException) -> JSONResponse:
detail = str(exc.detail) if exc.detail else "Request failed"
return anthropic_error_response(
detail,
status_code=exc.status_code,
error_type="invalid_request_error",
)

View File

@@ -0,0 +1,137 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class AnthropicTextBlock(BaseModel):
type: Literal["text"]
text: str
class AnthropicImageSource(BaseModel):
type: Literal["base64"]
media_type: str
data: str
class AnthropicImageBlock(BaseModel):
type: Literal["image"]
source: AnthropicImageSource
class AnthropicToolResultBlock(BaseModel):
type: Literal["tool_result"]
tool_use_id: str
content: str | list[dict[str, Any]]
is_error: bool | None = None
class AnthropicToolUseBlock(BaseModel):
type: Literal["tool_use"]
id: str
name: str
input: dict[str, Any]
class AnthropicThinkingBlock(BaseModel):
type: Literal["thinking"]
thinking: str
AnthropicContentBlock = (
AnthropicTextBlock
| AnthropicImageBlock
| AnthropicToolResultBlock
| AnthropicToolUseBlock
| AnthropicThinkingBlock
)
class AnthropicMessage(BaseModel):
role: Literal["user", "assistant"]
content: str | list[AnthropicContentBlock]
class AnthropicTool(BaseModel):
name: str
description: str | None = None
input_schema: dict[str, Any]
class AnthropicToolChoice(BaseModel):
type: Literal["auto", "any", "tool", "none"]
name: str | None = None
class AnthropicMetadata(BaseModel):
user_id: str | None = None
class AnthropicMessagesRequest(BaseModel):
model_config = ConfigDict(extra="allow")
model: str
messages: list[AnthropicMessage]
max_tokens: int = Field(default=1024, ge=1)
system: str | list[AnthropicTextBlock] | None = None
metadata: AnthropicMetadata | None = None
stop_sequences: list[str] | None = None
stream: bool | None = False
temperature: float | None = None
top_p: float | None = None
top_k: int | None = None
tools: list[AnthropicTool] | None = None
tool_choice: AnthropicToolChoice | None = None
class AnthropicUsage(BaseModel):
input_tokens: int = 0
output_tokens: int = 0
cache_creation_input_tokens: int | None = None
cache_read_input_tokens: int | None = None
class AnthropicMessageResponse(BaseModel):
id: str
type: Literal["message"] = "message"
role: Literal["assistant"] = "assistant"
content: list[AnthropicTextBlock | AnthropicToolUseBlock]
model: str
stop_reason: str | None
stop_sequence: str | None = None
usage: AnthropicUsage
class CountTokensRequest(BaseModel):
model_config = ConfigDict(extra="allow")
model: str
messages: list[AnthropicMessage]
system: str | list[AnthropicTextBlock] | None = None
tools: list[AnthropicTool] | None = None
class CountTokensResponse(BaseModel):
input_tokens: int
SUPPORTED_MODELS = [
{
"id": "claude-sonnet-4.6",
"display_name": "Claude Sonnet 4.6",
"owned_by": "anthropic",
},
{
"id": "claude-opus-4.6",
"display_name": "Claude Opus 4.6",
"owned_by": "anthropic",
},
{
"id": "claude-haiku-4.5",
"display_name": "Claude Haiku 4.5",
"owned_by": "anthropic",
},
]

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
import os
from typing import Any
import boto3
from botocore.exceptions import BotoCoreError, ClientError
from nexus_claude_api.config import Settings
from nexus_claude_api.errors import NexusClaudeError
class NexusClient:
def __init__(self, settings: Settings) -> None:
if settings.api_key:
os.environ["AWS_BEARER_TOKEN_BEDROCK"] = settings.api_key
self.settings = settings
self._client = boto3.client(
service_name="bedrock-runtime",
endpoint_url=settings.endpoint_url,
region_name="nexus",
)
def converse(self, request: dict[str, Any]) -> dict[str, Any]:
try:
return self._client.converse(**request)
except ClientError as exc:
raise _map_client_error(exc) from exc
except BotoCoreError as exc:
raise NexusClaudeError(
"Failed to call Nexus Converse API",
status_code=502,
error_type="api_error",
) from exc
def converse_stream(self, request: dict[str, Any]) -> Any:
try:
response = self._client.converse_stream(**request)
return response.get("stream", [])
except ClientError as exc:
raise _map_client_error(exc) from exc
except BotoCoreError as exc:
raise NexusClaudeError(
"Failed to call Nexus Converse Stream API",
status_code=502,
error_type="api_error",
) from exc
def _map_client_error(exc: ClientError) -> NexusClaudeError:
error = exc.response.get("Error", {})
code = error.get("Code", "")
message = error.get("Message", "Nexus API request failed")
status_code = int(exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode", 502))
if code in {"AccessDeniedException", "UnrecognizedClientException"}:
status_code = 403
elif code in {"ThrottlingException", "TooManyRequestsException"}:
status_code = 429
return NexusClaudeError(message, status_code=status_code, error_type="api_error")

View File

@@ -0,0 +1,2 @@
"""HTTP route modules."""

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter()
@router.get("/")
def root() -> dict[str, str]:
return {"status": "ok", "service": "nexus-claude-api"}
@router.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import ValidationError
from nexus_claude_api.errors import NexusClaudeError, anthropic_error_response
from nexus_claude_api.models import (
AnthropicMessagesRequest,
CountTokensRequest,
CountTokensResponse,
)
from nexus_claude_api.nexus_client import NexusClient
from nexus_claude_api.tokens import estimate_input_tokens
from nexus_claude_api.translators.anthropic_to_bedrock import anthropic_to_bedrock_request
from nexus_claude_api.translators.bedrock_to_anthropic import bedrock_to_anthropic_response
from nexus_claude_api.translators.stream import (
bedrock_stream_to_anthropic_events,
sse_frame,
)
router = APIRouter()
def get_nexus_client(request: Request) -> NexusClient:
return request.app.state.nexus_client
@router.post("/v1/messages", response_model=None)
async def create_message(
request: Request,
client: Annotated[NexusClient, Depends(get_nexus_client)],
) -> Response:
try:
raw = await request.json()
payload = AnthropicMessagesRequest.model_validate(raw)
bedrock_request = anthropic_to_bedrock_request(payload)
if payload.stream:
stream = client.converse_stream(bedrock_request)
return StreamingResponse(
(
sse_frame(event)
for event in bedrock_stream_to_anthropic_events(
stream,
model=payload.model,
)
),
media_type="text/event-stream",
)
response = client.converse(bedrock_request)
anthropic_response = bedrock_to_anthropic_response(
response,
model=payload.model,
)
return JSONResponse(content=anthropic_response.model_dump(exclude_none=True))
except ValidationError as exc:
return anthropic_error_response(str(exc), status_code=400)
except NexusClaudeError as exc:
return anthropic_error_response(
exc.message,
status_code=exc.status_code,
error_type=exc.error_type,
)
@router.post("/v1/messages/count_tokens")
async def count_tokens(request: Request) -> JSONResponse:
try:
raw = await request.json()
payload = CountTokensRequest.model_validate(raw)
response = CountTokensResponse(input_tokens=estimate_input_tokens(payload))
return JSONResponse(content=response.model_dump())
except ValidationError as exc:
return anthropic_error_response(str(exc), status_code=400)

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from fastapi import APIRouter
from nexus_claude_api.models import SUPPORTED_MODELS
router = APIRouter()
@router.get("/v1/models")
def list_models() -> dict[str, object]:
return {
"object": "list",
"data": [
{
"id": model["id"],
"object": "model",
"type": "model",
"created": 0,
"created_at": "1970-01-01T00:00:00.000Z",
"owned_by": model["owned_by"],
"display_name": model["display_name"],
}
for model in SUPPORTED_MODELS
],
"has_more": False,
}

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.requests import Request
from fastapi.responses import JSONResponse
from nexus_claude_api.config import Settings
from nexus_claude_api.errors import NexusClaudeError, anthropic_error_response
from nexus_claude_api.nexus_client import NexusClient
from nexus_claude_api.routes.health import router as health_router
from nexus_claude_api.routes.messages import router as messages_router
from nexus_claude_api.routes.models import router as models_router
def create_app(
settings: Settings | None = None,
nexus_client: NexusClient | None = None,
) -> FastAPI:
resolved_settings = settings or Settings.from_values(require_api_key=False)
app = FastAPI(title="nexus-claude-api", version="0.1.0")
app.state.settings = resolved_settings
app.state.nexus_client = nexus_client or NexusClient(resolved_settings)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health_router)
app.include_router(models_router)
app.include_router(messages_router)
@app.exception_handler(NexusClaudeError)
async def handle_nexus_error(_: Request, exc: NexusClaudeError) -> JSONResponse:
return anthropic_error_response(
exc.message,
status_code=exc.status_code,
error_type=exc.error_type,
)
logging.basicConfig(
level=logging.DEBUG if resolved_settings.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
return app

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from nexus_claude_api.config import DEFAULT_OPUS_MODEL, Settings
def generate_claude_code_powershell(settings: Settings) -> str:
env = {
"ANTHROPIC_BASE_URL": settings.base_url,
"ANTHROPIC_AUTH_TOKEN": "dummy",
"ANTHROPIC_MODEL": settings.model,
"ANTHROPIC_DEFAULT_SONNET_MODEL": settings.model,
"ANTHROPIC_DEFAULT_OPUS_MODEL": DEFAULT_OPUS_MODEL,
"ANTHROPIC_SMALL_FAST_MODEL": settings.small_model,
"ANTHROPIC_DEFAULT_HAIKU_MODEL": settings.small_model,
"DISABLE_NON_ESSENTIAL_MODEL_CALLS": "1",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
}
assignments = [f"$env:{key}='{value}'" for key, value in env.items()]
return "\n".join([*assignments, "claude"])

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
import json
from typing import Any
from nexus_claude_api.models import CountTokensRequest
def estimate_input_tokens(payload: CountTokensRequest | dict[str, Any]) -> int:
if hasattr(payload, "model_dump"):
text = json.dumps(payload.model_dump(), ensure_ascii=False, default=str)
else:
text = json.dumps(payload, ensure_ascii=False, default=str)
# Conservative local approximation: about 4 characters per token.
return max(1, (len(text) + 3) // 4)

View File

@@ -0,0 +1,2 @@
"""Anthropic and Bedrock translation helpers."""

View File

@@ -0,0 +1,164 @@
from __future__ import annotations
import base64
from typing import Any
from nexus_claude_api.config import resolve_backend_model
from nexus_claude_api.errors import NexusClaudeError
from nexus_claude_api.models import (
AnthropicContentBlock,
AnthropicImageBlock,
AnthropicMessage,
AnthropicMessagesRequest,
AnthropicTextBlock,
AnthropicThinkingBlock,
AnthropicToolResultBlock,
AnthropicToolUseBlock,
)
def anthropic_to_bedrock_request(payload: AnthropicMessagesRequest) -> dict[str, Any]:
request: dict[str, Any] = {
"modelId": resolve_backend_model(payload.model),
"messages": [_message_to_bedrock(message) for message in payload.messages],
}
system = _system_to_bedrock(payload.system)
if system:
request["system"] = system
inference_config: dict[str, Any] = {}
if payload.max_tokens:
inference_config["maxTokens"] = payload.max_tokens
if payload.temperature is not None:
inference_config["temperature"] = payload.temperature
if payload.top_p is not None:
inference_config["topP"] = payload.top_p
if inference_config:
request["inferenceConfig"] = inference_config
if payload.stop_sequences:
request["stopSequences"] = payload.stop_sequences
tool_config = _tools_to_bedrock(payload)
if tool_config:
request["toolConfig"] = tool_config
return request
def _system_to_bedrock(
system: str | list[AnthropicTextBlock] | None,
) -> list[dict[str, str]] | None:
if system is None:
return None
if isinstance(system, str):
return [{"text": system}]
return [{"text": block.text} for block in system]
def _message_to_bedrock(message: AnthropicMessage) -> dict[str, Any]:
return {
"role": message.role,
"content": _content_to_bedrock(message.content),
}
def _content_to_bedrock(
content: str | list[AnthropicContentBlock],
) -> list[dict[str, Any]]:
if isinstance(content, str):
return [{"text": content}]
blocks: list[dict[str, Any]] = []
for block in content:
if isinstance(block, AnthropicTextBlock):
blocks.append({"text": block.text})
elif isinstance(block, AnthropicThinkingBlock):
blocks.append({"text": block.thinking})
elif isinstance(block, AnthropicImageBlock):
blocks.append(_image_to_bedrock(block))
elif isinstance(block, AnthropicToolUseBlock):
blocks.append(
{
"toolUse": {
"toolUseId": block.id,
"name": block.name,
"input": block.input,
}
}
)
elif isinstance(block, AnthropicToolResultBlock):
blocks.append(
{
"toolResult": {
"toolUseId": block.tool_use_id,
"content": _tool_result_content(block.content),
**({"status": "error"} if block.is_error else {}),
}
}
)
else:
raise NexusClaudeError(f"Unsupported content block: {block!r}")
return blocks
def _image_to_bedrock(block: AnthropicImageBlock) -> dict[str, Any]:
media_type = block.source.media_type
if "/" not in media_type:
raise NexusClaudeError(f"Invalid image media_type: {media_type}")
image_format = media_type.split("/", 1)[1]
if image_format == "jpeg":
image_format = "jpg"
try:
image_bytes = base64.b64decode(block.source.data, validate=True)
except Exception as exc:
raise NexusClaudeError("Invalid base64 image data") from exc
return {
"image": {
"format": image_format,
"source": {"bytes": image_bytes},
}
}
def _tool_result_content(content: str | list[dict[str, Any]]) -> list[dict[str, Any]]:
if isinstance(content, str):
return [{"text": content}]
result: list[dict[str, Any]] = []
for item in content:
if item.get("type") == "text" and "text" in item:
result.append({"text": item["text"]})
else:
result.append({"json": item})
return result
def _tools_to_bedrock(payload: AnthropicMessagesRequest) -> dict[str, Any] | None:
if not payload.tools or (payload.tool_choice and payload.tool_choice.type == "none"):
return None
tool_config: dict[str, Any] = {
"tools": [
{
"toolSpec": {
"name": tool.name,
"description": tool.description or "",
"inputSchema": {"json": tool.input_schema},
}
}
for tool in payload.tools
]
}
if payload.tool_choice:
choice = payload.tool_choice
if choice.type == "auto":
tool_config["toolChoice"] = {"auto": {}}
elif choice.type == "any":
tool_config["toolChoice"] = {"any": {}}
elif choice.type == "tool" and choice.name:
tool_config["toolChoice"] = {"tool": {"name": choice.name}}
return tool_config

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
import time
import uuid
from typing import Any
from nexus_claude_api.models import (
AnthropicMessageResponse,
AnthropicTextBlock,
AnthropicToolUseBlock,
AnthropicUsage,
)
def bedrock_to_anthropic_response(
response: dict[str, Any],
*,
model: str,
) -> AnthropicMessageResponse:
output_message = response.get("output", {}).get("message", {})
content = _content_to_anthropic(output_message.get("content", []))
usage = response.get("usage", {})
return AnthropicMessageResponse(
id=response.get("ResponseMetadata", {}).get("RequestId")
or f"msg_{uuid.uuid4().hex}",
content=content or [AnthropicTextBlock(type="text", text="")],
model=model,
stop_reason=map_stop_reason(response.get("stopReason")),
stop_sequence=None,
usage=AnthropicUsage(
input_tokens=int(usage.get("inputTokens", 0) or 0),
output_tokens=int(usage.get("outputTokens", 0) or 0),
),
)
def _content_to_anthropic(
content: list[dict[str, Any]],
) -> list[AnthropicTextBlock | AnthropicToolUseBlock]:
blocks: list[AnthropicTextBlock | AnthropicToolUseBlock] = []
for block in content:
if "text" in block:
blocks.append(AnthropicTextBlock(type="text", text=str(block["text"])))
elif "toolUse" in block:
tool = block["toolUse"]
blocks.append(
AnthropicToolUseBlock(
type="tool_use",
id=str(tool.get("toolUseId") or tool.get("id") or ""),
name=str(tool.get("name") or ""),
input=tool.get("input") or {},
)
)
return blocks
def map_stop_reason(reason: str | None) -> str:
mapping = {
"end_turn": "end_turn",
"max_tokens": "max_tokens",
"stop_sequence": "stop_sequence",
"tool_use": "tool_use",
"endTurn": "end_turn",
"maxTokens": "max_tokens",
"stopSequence": "stop_sequence",
"toolUse": "tool_use",
}
return mapping.get(reason or "", "end_turn")
def message_start_event(*, message_id: str, model: str) -> dict[str, Any]:
return {
"type": "message_start",
"message": {
"id": message_id,
"type": "message",
"role": "assistant",
"content": [],
"model": model,
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": 0, "output_tokens": 0},
},
}
def now_message_id() -> str:
return f"msg_{int(time.time() * 1000)}_{uuid.uuid4().hex[:12]}"

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
import json
from typing import Any, Iterable
from nexus_claude_api.translators.bedrock_to_anthropic import (
map_stop_reason,
message_start_event,
now_message_id,
)
def sse_frame(event: dict[str, Any]) -> str:
event_type = event["type"]
return f"event: {event_type}\ndata: {json.dumps(event, ensure_ascii=False)}\n\n"
def bedrock_stream_to_anthropic_events(
events: Iterable[dict[str, Any]],
*,
model: str,
) -> Iterable[dict[str, Any]]:
message_id = now_message_id()
usage: dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
yield message_start_event(message_id=message_id, model=model)
open_blocks: set[int] = set()
final_stop_reason = "end_turn"
for event in events:
if "messageStart" in event:
continue
if "contentBlockStart" in event:
start = event["contentBlockStart"]
index = int(start.get("contentBlockIndex", start.get("index", 0)))
open_blocks.add(index)
block = start.get("start", {})
if "toolUse" in block:
tool = block["toolUse"]
content_block = {
"type": "tool_use",
"id": str(tool.get("toolUseId") or ""),
"name": str(tool.get("name") or ""),
"input": {},
}
else:
content_block = {"type": "text", "text": ""}
yield {
"type": "content_block_start",
"index": index,
"content_block": content_block,
}
elif "contentBlockDelta" in event:
delta_event = event["contentBlockDelta"]
index = int(delta_event.get("contentBlockIndex", delta_event.get("index", 0)))
if index not in open_blocks:
open_blocks.add(index)
yield {
"type": "content_block_start",
"index": index,
"content_block": {"type": "text", "text": ""},
}
delta = delta_event.get("delta", {})
if "text" in delta:
yield {
"type": "content_block_delta",
"index": index,
"delta": {"type": "text_delta", "text": delta["text"]},
}
elif "toolUse" in delta:
tool_delta = delta["toolUse"]
partial = tool_delta.get("input") or tool_delta.get("partialJson") or ""
if not isinstance(partial, str):
partial = json.dumps(partial, ensure_ascii=False)
yield {
"type": "content_block_delta",
"index": index,
"delta": {"type": "input_json_delta", "partial_json": partial},
}
elif "contentBlockStop" in event:
stop = event["contentBlockStop"]
index = int(stop.get("contentBlockIndex", stop.get("index", 0)))
open_blocks.discard(index)
yield {"type": "content_block_stop", "index": index}
elif "metadata" in event:
raw_usage = event["metadata"].get("usage", {})
usage["input_tokens"] = int(raw_usage.get("inputTokens", usage["input_tokens"]) or 0)
usage["output_tokens"] = int(
raw_usage.get("outputTokens", usage["output_tokens"]) or 0
)
elif "messageStop" in event:
final_stop_reason = map_stop_reason(event["messageStop"].get("stopReason"))
elif "error" in event:
yield {
"type": "error",
"error": {
"type": "api_error",
"message": str(event["error"]),
},
}
return
for index in sorted(open_blocks):
yield {"type": "content_block_stop", "index": index}
yield {
"type": "message_delta",
"delta": {"stop_reason": final_stop_reason, "stop_sequence": None},
"usage": usage,
}
yield {"type": "message_stop"}

63
tests/test_cli.py Normal file
View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import shutil
from pathlib import Path
from nexus_claude_api.cli import main
from nexus_claude_api.config import Settings, load_local_config
from nexus_claude_api.shell import generate_claude_code_powershell
def test_claude_code_command() -> None:
settings = Settings.from_values(
host="127.0.0.1",
port=4141,
api_key="test",
require_api_key=False,
)
command = generate_claude_code_powershell(settings)
assert "ANTHROPIC_BASE_URL" in command
assert "claude-sonnet-4.6" in command
assert command.endswith("claude")
def test_missing_api_key_fails(monkeypatch) -> None:
tmp_path = _workspace_tmp("missing-key")
monkeypatch.delenv("NEXUS_API_KEY", raising=False)
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
monkeypatch.chdir(tmp_path)
try:
exit_code = main(["start", "--dry-run"])
finally:
monkeypatch.chdir(Path(__file__).parents[1])
shutil.rmtree(tmp_path, ignore_errors=True)
assert exit_code == 2
def test_local_config_api_key(monkeypatch) -> None:
tmp_path = _workspace_tmp("local-config")
monkeypatch.chdir(tmp_path)
(tmp_path / "nexus-claude-api.local.json").write_text(
'{"api_key": "local-test-key"}',
encoding="utf-8",
)
try:
settings = Settings.from_values(require_api_key=False)
assert settings.api_key == "local-test-key"
assert load_local_config()["api_key"] == "local-test-key"
finally:
monkeypatch.chdir(Path(__file__).parents[1])
shutil.rmtree(tmp_path, ignore_errors=True)
def _workspace_tmp(name: str) -> Path:
path = Path(__file__).parents[1] / ".test-tmp" / name
shutil.rmtree(path, ignore_errors=True)
path.mkdir(parents=True, exist_ok=True)
return path

96
tests/test_routes.py Normal file
View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from nexus_claude_api.config import Settings
from nexus_claude_api.server import create_app
class FakeNexusClient:
def converse(self, request: dict) -> dict:
assert request["messages"][0]["content"][0]["text"] == "Hello"
return {
"ResponseMetadata": {"RequestId": "req-route"},
"output": {"message": {"content": [{"text": "Hi"}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 3, "outputTokens": 1},
}
def converse_stream(self, request: dict):
assert request["messages"][0]["content"][0]["text"] == "Hello"
return [
{"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "Hi"}}},
{"contentBlockStop": {"contentBlockIndex": 0}},
{"messageStop": {"stopReason": "end_turn"}},
]
def client() -> TestClient:
settings = Settings.from_values(api_key="test", require_api_key=False)
return TestClient(create_app(settings=settings, nexus_client=FakeNexusClient()))
def test_health() -> None:
response = client().get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_models() -> None:
response = client().get("/v1/models")
assert response.status_code == 200
data = response.json()["data"]
assert {model["id"] for model in data} >= {
"claude-sonnet-4.6",
"claude-opus-4.6",
"claude-haiku-4.5",
}
def test_messages_non_stream() -> None:
response = client().post(
"/v1/messages",
json={
"model": "claude-sonnet-4.6",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 32,
},
)
assert response.status_code == 200
body = response.json()
assert body["id"] == "req-route"
assert body["content"][0]["text"] == "Hi"
def test_messages_stream() -> None:
with client().stream(
"POST",
"/v1/messages",
json={
"model": "claude-sonnet-4.6",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 32,
"stream": True,
},
) as response:
body = response.read().decode("utf-8")
assert response.status_code == 200
assert "event: message_start" in body
assert "event: content_block_delta" in body
assert "event: message_stop" in body
def test_count_tokens() -> None:
response = client().post(
"/v1/messages/count_tokens",
json={
"model": "claude-sonnet-4.6",
"messages": [{"role": "user", "content": "Hello"}],
},
)
assert response.status_code == 200
assert response.json()["input_tokens"] > 0

148
tests/test_translators.py Normal file
View File

@@ -0,0 +1,148 @@
from __future__ import annotations
import base64
from nexus_claude_api.models import AnthropicMessagesRequest
from nexus_claude_api.translators.anthropic_to_bedrock import anthropic_to_bedrock_request
from nexus_claude_api.translators.bedrock_to_anthropic import bedrock_to_anthropic_response
from nexus_claude_api.translators.stream import bedrock_stream_to_anthropic_events
def test_text_request_translation() -> None:
payload = AnthropicMessagesRequest.model_validate(
{
"model": "claude-sonnet-4.6",
"messages": [{"role": "user", "content": "Hello"}],
"system": "You are helpful.",
"max_tokens": 100,
"temperature": 0.2,
}
)
request = anthropic_to_bedrock_request(payload)
assert request["modelId"] == "claude-sonnet-4.6"
assert request["messages"] == [{"role": "user", "content": [{"text": "Hello"}]}]
assert request["system"] == [{"text": "You are helpful."}]
assert request["inferenceConfig"]["maxTokens"] == 100
assert request["inferenceConfig"]["temperature"] == 0.2
def test_image_request_translation() -> None:
image_data = base64.b64encode(b"image-bytes").decode("ascii")
payload = AnthropicMessagesRequest.model_validate(
{
"model": "claude-sonnet-4.6",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this"},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": image_data,
},
},
],
}
],
"max_tokens": 100,
}
)
request = anthropic_to_bedrock_request(payload)
image_block = request["messages"][0]["content"][1]["image"]
assert image_block["format"] == "png"
assert image_block["source"]["bytes"] == b"image-bytes"
def test_tool_translation() -> None:
payload = AnthropicMessagesRequest.model_validate(
{
"model": "claude-sonnet-4.6",
"messages": [
{"role": "user", "content": "Use a tool"},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_1",
"name": "get_weather",
"input": {"city": "Berlin"},
}
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": "Sunny",
}
],
},
],
"tools": [
{
"name": "get_weather",
"description": "Get weather",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
}
],
"tool_choice": {"type": "auto"},
"max_tokens": 100,
}
)
request = anthropic_to_bedrock_request(payload)
assert request["messages"][1]["content"][0]["toolUse"]["toolUseId"] == "toolu_1"
assert request["messages"][2]["content"][0]["toolResult"]["toolUseId"] == "toolu_1"
assert request["toolConfig"]["tools"][0]["toolSpec"]["name"] == "get_weather"
assert request["toolConfig"]["toolChoice"] == {"auto": {}}
def test_bedrock_response_translation() -> None:
response = {
"ResponseMetadata": {"RequestId": "req-1"},
"output": {"message": {"content": [{"text": "Hello there"}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 10, "outputTokens": 3},
}
translated = bedrock_to_anthropic_response(response, model="claude-sonnet-4.6")
assert translated.id == "req-1"
assert translated.content[0].type == "text"
assert translated.content[0].text == "Hello there"
assert translated.usage.input_tokens == 10
assert translated.usage.output_tokens == 3
def test_stream_translation() -> None:
events = [
{"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "Hi"}}},
{"contentBlockStop": {"contentBlockIndex": 0}},
{"metadata": {"usage": {"inputTokens": 4, "outputTokens": 1}}},
{"messageStop": {"stopReason": "end_turn"}},
]
translated = list(
bedrock_stream_to_anthropic_events(events, model="claude-sonnet-4.6")
)
assert translated[0]["type"] == "message_start"
assert any(event["type"] == "content_block_delta" for event in translated)
assert translated[-2]["type"] == "message_delta"
assert translated[-2]["usage"]["input_tokens"] == 4
assert translated[-1]["type"] == "message_stop"

449
uv.lock generated Normal file
View File

@@ -0,0 +1,449 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
]
[[package]]
name = "boto3"
version = "1.43.36"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/9f/897287e955db0f50b12fd69ef45956e4fd2c7ddb48c736872f7ea2314443/boto3-1.43.36.tar.gz", hash = "sha256:587d7ee92a12e440ad12b0e7f11f3358f0c4d65b19f64726efc94aaf194aff28", size = 112690, upload-time = "2026-06-23T02:47:14.561Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/f1/274303f52483ecf199eae6f8d9b6f5951670397ee4d72c06cfd4eb644612/boto3-1.43.36-py3-none-any.whl", hash = "sha256:42942dde254673abcbc9e6e60017c88341a4f49d99d24e1f2e290fb38138c26f", size = 140031, upload-time = "2026-06-23T02:47:13.178Z" },
]
[[package]]
name = "botocore"
version = "1.43.36"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/37/da9e7f6ca73ac73afd7f0bb7f238aa5daba35c081e98d7f48a7c399599c0/botocore-1.43.36.tar.gz", hash = "sha256:4cae47d1b2d426316b85a0087d9e69e048f13bc003b5177d74639fe9dfd28205", size = 15625488, upload-time = "2026-06-23T02:47:03.192Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/19/934f81592527a3f7f9b943c893e334c721a4644948642bc33885d584e9ec/botocore-1.43.36-py3-none-any.whl", hash = "sha256:3c65fdc39ed01d8dfde1e961b34038aed03c459f8ddf80717a12ac006475e49d", size = 15313630, upload-time = "2026-06-23T02:46:59.327Z" },
]
[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.138.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/c9/5e8defe249899c0dc900643695fc07829a67fc88b4ff2cdb03fcbdbf5a4b/fastapi-0.138.1.tar.gz", hash = "sha256:96e3702dce09ee0dce48856135620d3d865ca684a79fe7513fd7b13a12f82862", size = 419646, upload-time = "2026-06-25T15:40:42.115Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/a9/69a6924f645eb4dd8cd625bf255b3625990eb3e14e073438a53c405dcd3e/fastapi-0.138.1-py3-none-any.whl", hash = "sha256:b994cae7ba8b82c976a728b544244de31333fa5f7d261f9a1dffe526444cae23", size = 129182, upload-time = "2026-06-25T15:40:40.771Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
name = "nexus-claude-api"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "boto3" },
{ name = "fastapi" },
{ name = "pydantic" },
{ name = "uvicorn" },
]
[package.optional-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "boto3", specifier = ">=1.34.0" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
{ name = "pydantic", specifier = ">=2.8.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "uvicorn", specifier = ">=0.30.0" },
]
provides-extras = ["dev"]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "s3transfer"
version = "0.19.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/94/dcdaeb1713cab9c84def276cfac7388b17c7d9855bbcfe88d77e4dbafd44/s3transfer-0.19.0.tar.gz", hash = "sha256:ce436931687addc4c1712d52d40b32f53e88315723f107ffa20ba82b05a0f685", size = 165171, upload-time = "2026-06-16T19:44:51.599Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/5f/4c174edad94f82de888ac00a5ddd8d07b35609b6c94f0bdf4d74af57703e/s3transfer-0.19.0-py3-none-any.whl", hash = "sha256:777cc2415536f1debadb5c2ef7779275d0fc0fe0e042411cdd6caebeb2685262", size = 90101, upload-time = "2026-06-16T19:44:50.439Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "starlette"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
name = "uvicorn"
version = "0.49.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
]