initial commits
This commit is contained in:
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
52
README.md
Normal 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
243
docs/AI_NEXUS_CLAUDE.md
Normal 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
86
docs/PRD.md
Normal 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
217
docs/REQUIREMENTS_DESIGN.md
Normal 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
33
pyproject.toml
Normal 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"]
|
||||||
|
|
||||||
4
src/nexus_claude_api/__init__.py
Normal file
4
src/nexus_claude_api/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""Local Anthropic-compatible proxy for AI Nexus Claude models."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
6
src/nexus_claude_api/__main__.py
Normal file
6
src/nexus_claude_api/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from nexus_claude_api.cli import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
||||||
82
src/nexus_claude_api/cli.py
Normal file
82
src/nexus_claude_api/cli.py
Normal 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())
|
||||||
90
src/nexus_claude_api/config.py
Normal file
90
src/nexus_claude_api/config.py
Normal 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}
|
||||||
46
src/nexus_claude_api/errors.py
Normal file
46
src/nexus_claude_api/errors.py
Normal 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",
|
||||||
|
)
|
||||||
|
|
||||||
137
src/nexus_claude_api/models.py
Normal file
137
src/nexus_claude_api/models.py
Normal 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",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
60
src/nexus_claude_api/nexus_client.py
Normal file
60
src/nexus_claude_api/nexus_client.py
Normal 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")
|
||||||
|
|
||||||
2
src/nexus_claude_api/routes/__init__.py
Normal file
2
src/nexus_claude_api/routes/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""HTTP route modules."""
|
||||||
|
|
||||||
17
src/nexus_claude_api/routes/health.py
Normal file
17
src/nexus_claude_api/routes/health.py
Normal 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"}
|
||||||
|
|
||||||
78
src/nexus_claude_api/routes/messages.py
Normal file
78
src/nexus_claude_api/routes/messages.py
Normal 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)
|
||||||
29
src/nexus_claude_api/routes/models.py
Normal file
29
src/nexus_claude_api/routes/models.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
||||||
52
src/nexus_claude_api/server.py
Normal file
52
src/nexus_claude_api/server.py
Normal 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
|
||||||
|
|
||||||
20
src/nexus_claude_api/shell.py
Normal file
20
src/nexus_claude_api/shell.py
Normal 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"])
|
||||||
|
|
||||||
16
src/nexus_claude_api/tokens.py
Normal file
16
src/nexus_claude_api/tokens.py
Normal 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)
|
||||||
|
|
||||||
2
src/nexus_claude_api/translators/__init__.py
Normal file
2
src/nexus_claude_api/translators/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Anthropic and Bedrock translation helpers."""
|
||||||
|
|
||||||
164
src/nexus_claude_api/translators/anthropic_to_bedrock.py
Normal file
164
src/nexus_claude_api/translators/anthropic_to_bedrock.py
Normal 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
|
||||||
|
|
||||||
89
src/nexus_claude_api/translators/bedrock_to_anthropic.py
Normal file
89
src/nexus_claude_api/translators/bedrock_to_anthropic.py
Normal 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]}"
|
||||||
|
|
||||||
118
src/nexus_claude_api/translators/stream.py
Normal file
118
src/nexus_claude_api/translators/stream.py
Normal 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
63
tests/test_cli.py
Normal 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
96
tests/test_routes.py
Normal 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
148
tests/test_translators.py
Normal 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
449
uv.lock
generated
Normal 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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user