# SDKRouter

Unified Python SDK for AI services with OpenAI compatibility. Access 300+ LLM models through a single interface, plus vision analysis, CDN, URL shortening, and HTML cleaning tools.

## Installation

```bash
pip install sdkrouter
```

## Quick Start

```python
from sdkrouter import SDKRouter, Model

client = SDKRouter(api_key="your-api-key")

# OpenAI-compatible chat completions with Model builder
response = client.chat.completions.create(
    model=Model.cheap(),  # or "openai/gpt-4o-mini" for direct ID
    messages=[{"role": "user", "content": "Hello!"}]
)
print(response.choices[0].message.content)
```

## Features

### Chat Completions (OpenAI-Compatible)

```python
from sdkrouter import Model

# Non-streaming with smart model
response = client.chat.completions.create(
    model=Model.smart(),
    messages=[{"role": "user", "content": "Explain quantum computing"}],
    max_tokens=500,
)

# Streaming with fast model
for chunk in client.chat.completions.create(
    model=Model.fast(streaming=True),
    messages=[{"role": "user", "content": "Count to 5"}],
    stream=True,
):
    print(chunk.choices[0].delta.content or "", end="")

# Direct model ID still works
response = client.chat.completions.create(
    model="anthropic/claude-sonnet-4",
    messages=[{"role": "user", "content": "Hello!"}],
)
```

### Intent-Based Model Routing

Use the `Model` builder for IDE autocomplete and validation, or raw alias strings:

```python
from sdkrouter import Model

# Model builder (recommended) — IDE autocomplete on methods and kwargs
response = client.chat.completions.create(
    model=Model.cheap(),  # Cheapest available model
    messages=[{"role": "user", "content": "Hello!"}]
)

response = client.chat.completions.create(
    model=Model.smart(),  # Highest quality model
    messages=[{"role": "user", "content": "Write a poem"}]
)

response = client.chat.completions.create(
    model=Model.balanced(),  # Best value (quality/price ratio)
    messages=[{"role": "user", "content": "Summarize this article"}]
)

# Raw string syntax also works
response = client.chat.completions.create(
    model="@cheap",
    messages=[{"role": "user", "content": "Hello!"}]
)
```

#### Available Presets

| Preset | Model Builder | Description |
|--------|---------------|-------------|
| `@cheap` | `Model.cheap()` | Lowest cost models |
| `@budget` | `Model.budget()` | Budget-friendly with decent quality |
| `@standard` | `Model.standard()` | Standard tier |
| `@balanced` | `Model.balanced()` | Best value models |
| `@smart` | `Model.smart()` | Highest quality models |
| `@fast` | `Model.fast()` | Fastest response times |
| `@premium` | `Model.premium()` | Top-tier premium models |

#### Capability Modifiers

Add capabilities with `+modifier` syntax or boolean kwargs:

```python
from sdkrouter import Model

# Cheapest model with vision support
response = client.chat.completions.create(
    model=Model.cheap(vision=True),  # or "@cheap+vision"
    messages=[...]
)

# Best quality model with tool use and long context
response = client.chat.completions.create(
    model=Model.smart(tools=True, long=True),  # or "@smart+tools+long"
    messages=[...]
)

# Balanced model with JSON mode
response = client.chat.completions.create(
    model=Model.balanced(json=True),  # or "@balanced+json"
    messages=[...]
)
```

| Modifier | Kwarg | Description |
|----------|-------|-------------|
| `+vision` | `vision=True` | Requires image input support |
| `+tools` | `tools=True` | Requires function/tool calling |
| `+json` | `json=True` | Requires JSON output mode |
| `+streaming` | `streaming=True` | Requires streaming support |
| `+long` | `long=True` | Requires 100k+ context window |

#### Category Modifiers

Filter by use case categories:

```python
from sdkrouter import Model

# Best coding model
response = client.chat.completions.create(
    model=Model.smart(code=True),  # or "@smart+code"
    messages=[...]
)

# Cheapest reasoning model with vision
response = client.chat.completions.create(
    model=Model.cheap(reasoning=True, vision=True),  # or "@cheap+reasoning+vision"
    messages=[...]
)

# Best value creative model with tools
response = client.chat.completions.create(
    model=Model.balanced(creative=True, tools=True),
    messages=[...]
)
```

| Category | Kwarg | Models optimized for |
|----------|-------|---------------------|
| `+code` | `code=True` | Programming and code generation |
| `+reasoning` | `reasoning=True` | Complex problem solving |
| `+creative` | `creative=True` | Creative writing, storytelling |
| `+analysis` | `analysis=True` | Data analysis, research |
| `+chat` | `chat=True` | Conversational interactions |
| `+agents` | `agents=True` | Tool use and autonomous agents |

#### Escape Hatch

Build aliases from raw strings when needed:

```python
from sdkrouter import Model

# For custom or dynamic combinations
alias = Model.alias("cheap", "vision", "code")  # "@cheap+vision+code"
```

### Structured Output (Pydantic)

Get type-safe responses with automatic JSON schema generation:

```python
from pydantic import BaseModel, Field
from sdkrouter import SDKRouter, Model

class Step(BaseModel):
    explanation: str = Field(description="Explanation of the step")
    result: str = Field(description="Result of this step")

class MathSolution(BaseModel):
    steps: list[Step] = Field(description="Solution steps")
    final_answer: float = Field(description="The final answer")

client = SDKRouter()
result = client.parse(
    model=Model.smart(json=True),
    messages=[
        {"role": "system", "content": "You are a math tutor. Show your work."},
        {"role": "user", "content": "Solve: 3x + 7 = 22"},
    ],
    response_format=MathSolution,
)

solution = result.choices[0].message.parsed
for i, step in enumerate(solution.steps, 1):
    print(f"{i}. {step.explanation} → {step.result}")
print(f"Answer: x = {solution.final_answer}")
```

### Vision Analysis

```python
from pathlib import Path
from sdkrouter import Model

# Analyze from URL
result = client.vision.analyze(
    image_url="https://example.com/image.jpg",
    prompt="Describe this image",
)
print(result.description)
print(f"Cost: ${result.cost_usd:.6f}")

# Analyze with model alias
result = client.vision.analyze(
    image_url="https://example.com/image.jpg",
    prompt="Describe this image",
    model=Model.smart(vision=True),
)

# Analyze from local file (auto-converts to base64)
result = client.vision.analyze(
    image_path=Path("./photo.jpg"),
    prompt="Describe this image",
)
```

#### Quality Tiers

| Tier | Model | Use Case |
|------|-------|----------|
| `fast` | gpt-4o-mini | Quick analysis, lower cost |
| `balanced` | gpt-4o | Default, good quality/cost ratio |
| `best` | claude-sonnet-4 | Highest accuracy |

```python
result = client.vision.analyze(
    image_url="https://example.com/image.jpg",
    model_quality="best",  # fast | balanced | best
)
```

### OCR (Text Extraction)

```python
from pathlib import Path

# OCR from URL
result = client.vision.ocr(
    image_url="https://example.com/document.jpg",
    language_hint="en",  # optional
)
print(result.text)

# OCR from local file (auto-converts to base64)
result = client.vision.ocr(
    image_path=Path("./document.jpg"),
)
```

#### OCR Modes

| Mode | Speed | Accuracy | Use Case |
|------|-------|----------|----------|
| `tiny` | Fastest | Basic | Simple text, receipts |
| `small` | Fast | Good | Standard documents |
| `base` | Medium | High | Default, balanced |
| `maximum` | Slow | Best | Complex layouts, handwriting |

```python
result = client.vision.ocr(
    image_url="https://example.com/document.jpg",
    mode="maximum",  # tiny | small | base | maximum
)
```

### CDN File Storage

```python
from pathlib import Path

# Upload from file path
file = client.cdn.upload(
    Path("./image.png"),
    is_public=True,
)
print(file.url)

# Upload bytes directly
file = client.cdn.upload(
    b"file content",
    filename="document.txt",
    is_public=True,
)

# Upload from URL (server downloads)
file = client.cdn.upload(
    url="https://example.com/image.png",
    filename="image.png",
)

# List files
files = client.cdn.list(page=1, page_size=20)
for f in files.results:
    print(f"{f.filename}: {f.size_bytes} bytes")

# Get file details
file = client.cdn.get("file-uuid")

# Delete file
client.cdn.delete("file-uuid")

# Statistics
stats = client.cdn.stats()
print(f"Total files: {stats.total_files}")
print(f"Total size: {stats.total_size_bytes} bytes")
```

### URL Shortener

```python
# Create short link
link = client.shortlinks.create(
    target_url="https://example.com/very-long-url-here",
    custom_slug="my-link",  # optional
    max_hits=1000,  # optional limit
)
print(link.short_url)
print(link.code)

# List links
links = client.shortlinks.list()
for link in links.results:
    print(f"{link.code}: {link.hit_count} hits")

# Statistics
stats = client.shortlinks.stats()
print(f"Total links: {stats.total_links}")
print(f"Total hits: {stats.total_hits}")
```

### HTML Cleaner

```python
result = client.cleaner.clean(
    html_content,
    output_format="markdown",  # html | markdown
    remove_scripts=True,
    remove_styles=True,
    max_tokens=4000,  # optional token limit
)
print(result.cleaned_html)
print(f"Original: {result.original_size} bytes")
print(f"Cleaned: {result.cleaned_size} bytes")
print(f"Compression: {result.compression_ratio:.1f}x")
```

#### Async Cleaning with Agent

For complex HTML or when you need extraction patterns:

```python
# Submit and wait for results (recommended)
result = client.cleaner.clean_async(
    html_content,
    url="https://example.com/article",  # source URL for context
    task_prompt="Extract main article content, ignore navigation",
    output_format="markdown",
    wait=True,  # poll until complete
)
print(result.cleaned_html)

# Or submit without waiting (for manual polling)
job = client.cleaner.clean_async(
    html_content,
    task_prompt="Extract product details",
)
print(f"Job queued: {job.request_uuid}")

# Poll job status manually
status = client.cleaner.job_status(job.request_uuid)
print(f"Status: {status.status}")

# Get extraction patterns (reusable for similar pages)
patterns = client.cleaner.patterns(job.request_uuid)
for p in patterns.patterns:
    print(f"Selector: {p['selector']} ({p['type']})")

# Get full result
result = client.cleaner.get(job.request_uuid)
print(result.cleaned_html)
```

### Web Search

Search the web using Anthropic's `web_search` tool:

```python
from sdkrouter import SDKRouter, UserLocation

client = SDKRouter()

# Basic web search
result = client.search.query("latest AI developments 2026", model="claude-haiku-4-5-20251001")
print(result.content)
print(f"Cost: ${result.cost_usd}")

# View citations
for citation in result.citations:
    print(f"- {citation.title}: {citation.url}")

# Search with domain filtering and explicit model
result = client.search.query(
    "Python tutorials",
    model="claude-haiku-4-5-20251001",
    allowed_domains=["python.org", "realpython.com"],
    blocked_domains=["spam-site.com"],
)

# Localized search
result = client.search.query(
    "weather forecast",
    model="claude-haiku-4-5-20251001",
    user_location=UserLocation(country="US", city="San Francisco"),
)

# Fetch and analyze specific URL
result = client.search.fetch(
    "https://example.com/article",
    prompt="Extract the main points from this article",
    model="claude-haiku-4-5-20251001",
)
```

#### Mode-Based Search

Use progressive search modes for different levels of analysis:

```python
from sdkrouter import SDKRouter, SearchMode

client = SDKRouter()

# Research mode: LLM ranking + summary
results = client.search.query_async(
    "best Python web frameworks 2026",
    mode=SearchMode.RESEARCH,
    model="claude-haiku-4-5-20251001",  # Cost-efficient model
    task_prompt="Rank by popularity and documentation quality",
    max_results=20,
    wait=True,
)
for item in results.ranked_results:
    print(f"- {item.title} (relevance: {item.relevance}, score: {item.relevance_score})")
print(f"Summary: {results.summary}")

# Get full results with metrics
result = client.search.results(str(results.uuid))
if result.agent_metrics:
    m = result.agent_metrics
    print(f"Duration: {m.total_duration_ms}ms")
    print(f"Cost: ${m.cost_usd}")
```

#### Analyze Mode: Entity Extraction

```python
# Analyze mode adds entity extraction
results = client.search.query_async(
    "latest AI startup funding rounds 2026",
    mode=SearchMode.ANALYZE,
    model="claude-haiku-4-5-20251001",
    task_prompt="Focus on funding news",
    wait=True,
)

# Get full results with entities
result = client.search.results(str(results.uuid))
if result.entities:
    for company in result.entities.companies or []:
        print(f"Company: {company.value} - {company.entity_context}")
    for amount in result.entities.amounts or []:
        print(f"Amount: {amount.value}")
```

#### Comprehensive Mode: Deep Analysis

```python
# Comprehensive mode: fetches URL content for synthesis
results = client.search.query_async(
    "climate change policy updates 2026",
    mode=SearchMode.COMPREHENSIVE,
    model="claude-haiku-4-5-20251001",
    task_prompt="Compare policy approaches across countries",
    wait=True,
    timeout=600.0,
)

result = client.search.results(str(results.uuid))
print(f"Synthesis: {result.synthesis}")
print(f"Detailed analysis: {len(result.detailed_analysis or [])} sources")
```

#### Search Modes

| Mode | Capabilities | Use Case |
|------|-------------|----------|
| `search` | Direct web search | Fast, simple queries |
| `research` | + LLM ranking, summary | Ranked results with insights |
| `analyze` | + Entity extraction | Extract companies, people, amounts |
| `comprehensive` | + URL fetch, synthesis | Deep content analysis |
| `investigate` | + Multi-query, cross-analysis | Complex investigations |

### Embeddings

Create text embeddings for semantic search and similarity:

```python
# Single text embedding
result = client.embeddings.create("Hello, world!")
embedding = result.data[0].embedding
print(f"Dimensions: {len(embedding)}")

# Batch embeddings
texts = ["Python programming", "JavaScript coding", "Machine learning"]
result = client.embeddings.create(texts)
for i, item in enumerate(result.data):
    print(f"[{i}] {len(item.embedding)} dimensions")

# Custom model (larger dimensions)
result = client.embeddings.create(
    "Hello, world!",
    model="openai/text-embedding-3-large",  # 3072 dimensions
)
```

#### Available Models

| Model | Dimensions | Use Case |
|-------|-----------|----------|
| `openai/text-embedding-3-small` | 1536 | Fast, cheap, default |
| `openai/text-embedding-3-large` | 3072 | Higher quality |
| `openai/text-embedding-ada-002` | 1536 | Legacy |

### LLM Models API

```python
# List available models with pagination
models = client.llm_models.list(page=1, page_size=50)
for m in models.results:
    print(f"{m.model_id}: context={m.context_length}")

# Get model details
model = client.llm_models.get("openai/gpt-4o-mini")
print(f"Context: {model.context_length} tokens")
print(f"Vision: {model.supports_vision}")
print(f"Price: ${model.pricing.prompt}/M input")

# List providers
providers = client.llm_models.providers()
for p in providers.providers:
    print(f"{p.name}: {p.model_count} models")

# Calculate cost
cost = client.llm_models.calculate_cost(
    "openai/gpt-4o-mini",
    input_tokens=1000,
    output_tokens=500,
)
print(f"Input: ${cost.input_cost_usd:.6f}")
print(f"Output: ${cost.output_cost_usd:.6f}")
print(f"Total: ${cost.total_cost_usd:.6f}")

# Statistics
stats = client.llm_models.stats()
print(f"Total models: {stats.total_models}")
print(f"Vision models: {stats.vision_models}")
```

### Token Utilities

```python
from sdkrouter.utils import count_tokens, count_messages_tokens

# Count tokens in text
tokens = count_tokens("Hello, world!")
print(f"Tokens: {tokens}")

# Count tokens in messages
messages = [
    {"role": "system", "content": "You are helpful."},
    {"role": "user", "content": "Hello!"},
]
tokens = count_messages_tokens(messages)
print(f"Message tokens: {tokens}")
```

### Logging

Built-in logging with Rich console output and file persistence:

```python
from sdkrouter import get_logger

# Get a configured logger
log = get_logger(__name__)
log.info("Processing request")
log.debug("Debug details: %s", data)
log.error("Something failed", exc_info=True)

# With custom settings
log = get_logger(__name__, level="DEBUG", log_to_file=True)
```

#### Features

- Rich console output with colors and formatted tracebacks
- Automatic file logging with date-based rotation
- Auto-detection of project root for log directory
- Cross-platform log paths (macOS, Windows, Linux)
- Fallback to standard logging if Rich not installed

```python
from sdkrouter import setup_logging, get_log_dir, find_project_root

# Configure logging globally
setup_logging(
    level="DEBUG",        # DEBUG | INFO | WARNING | ERROR | CRITICAL
    log_to_file=True,     # Write to file
    log_to_console=True,  # Output to console
    app_name="myapp",     # Log file prefix
    rich_tracebacks=True, # Rich exception formatting
)

# Get log directory path
log_dir = get_log_dir()  # e.g., /project/logs or ~/Library/Logs/sdkrouter

# Find project root
root = find_project_root()  # Searches for pyproject.toml, .git, etc.
```

## Async Support

All features support async operations:

```python
from sdkrouter import AsyncSDKRouter, Model
import asyncio

async def main():
    client = AsyncSDKRouter(api_key="your-api-key")

    # Async chat with Model builder
    response = await client.chat.completions.create(
        model=Model.cheap(),
        messages=[{"role": "user", "content": "Hello!"}]
    )

    # Async structured output
    result = await client.parse(
        model=Model.smart(json=True),
        messages=[...],
        response_format=MyModel,
    )

    # Parallel requests
    results = await asyncio.gather(
        client.vision.analyze(image_url="..."),
        client.cdn.list(),
        client.llm_models.stats(),
    )

asyncio.run(main())
```

## Configuration

```python
from sdkrouter import SDKRouter

# Environment variables (auto-loaded)
# SDKROUTER_API_KEY - API key
# SDKROUTER_BASE_URL - Custom base URL

# Direct configuration
client = SDKRouter(
    api_key="your-key",
    base_url="https://your-server.com",
    timeout=60.0,
    max_retries=3,
)

# Use OpenRouter directly
client = SDKRouter(
    openrouter_api_key="your-openrouter-key",
    use_self_hosted=False,
)
```

## Type Safety

All responses are fully typed with Pydantic models:

```python
from sdkrouter import Model
from sdkrouter.tools import (
    VisionAnalyzeResponse,
    OCRResponse,
    CDNFileDetail,
    ShortLinkDetail,
    CleanResponse,
    LLMModelDetail,
)

# IDE autocomplete works
result: VisionAnalyzeResponse = client.vision.analyze(...)
result.description  # str
result.cost_usd     # float
result.usage.total_tokens  # int
```

## Exports

```python
from sdkrouter import (
    # Clients
    SDKRouter,
    AsyncSDKRouter,
    # Model alias builder
    Model,
    # Enums for advanced use
    Tier,       # PresetSlug enum
    Category,   # CategorySlug enum
    Capability, # Capability enum
    # Types
    ModelInfo,
    ModelPricing,
    # Search
    SearchMode,
    UserLocation,
    # And more...
)
```

## Supported Models

Access 300+ models from providers:

- **OpenAI**: GPT-4.5, GPT-4o, o3, o3-mini, o1, o1-mini
- **Anthropic**: Claude Opus 4.5, Claude Sonnet 4, Claude 3.5 Sonnet
- **Google**: Gemini 2.5 Pro, Gemini 2.0 Flash
- **Meta**: Llama 4, Llama 3.3, Llama 3.2
- **Mistral**: Mistral Large, Mixtral, Codestral
- **DeepSeek**: DeepSeek V3, DeepSeek R1
- And many more via OpenRouter

## License

MIT
