llmshim 0.2.3__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {llmshim-0.2.3 → llmshim-0.3.0}/CLAUDE.md +8 -4
- {llmshim-0.2.3 → llmshim-0.3.0}/Cargo.lock +1 -1
- {llmshim-0.2.3 → llmshim-0.3.0}/Cargo.toml +1 -1
- {llmshim-0.2.3 → llmshim-0.3.0}/PKG-INFO +1 -1
- {llmshim-0.2.3 → llmshim-0.3.0}/README.md +8 -1
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/reference/configuration.md +1 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/reference/models.md +7 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/reference/providers.md +2 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/reference/request-fields.md +1 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/config.rs +3 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/main.rs +9 -1
- {llmshim-0.2.3 → llmshim-0.3.0}/src/providers/mod.rs +1 -0
- llmshim-0.3.0/src/providers/openrouter.rs +283 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/router.rs +4 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/vision.rs +44 -0
- llmshim-0.3.0/tests/integration_openrouter.rs +118 -0
- llmshim-0.3.0/tests/unit_openrouter.rs +316 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_router.rs +23 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_vision.rs +28 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/.github/workflows/pages.yml +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/.github/workflows/release.yml +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/.gitignore +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/CONTRIBUTING.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/LICENSE-APACHE +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/LICENSE-MIT +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/NOTICE +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/SECURITY.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/benchmarks/bench.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/benchmarks/bench_python.py +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/benchmarks/loadtest.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/.gitignore +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/book.toml +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/mermaid-init.js +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/mermaid.min.js +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/SUMMARY.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/concepts/contracts.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/concepts/conversations.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/concepts/portability.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/concepts/routing.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/concepts/translation-flow.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/guides/fallbacks.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/guides/images.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/guides/native-controls.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/guides/reasoning.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/guides/streaming.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/guides/tools.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/introduction.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/proxy/deployment.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/proxy/http-api.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/proxy/scaling.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/reference/api.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/reference/cli.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/reference/errors.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/reference/surfaces.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/start/choose.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/start/cli.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/start/clients.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/start/configure.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/start/proxy.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/docs/src/start/rust.md +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/examples/chat.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/examples/stream.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/llmshim/__init__.py +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/llmshim/_client.py +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/llmshim/_server.py +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/llmshim/types.py +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/pyproject.toml +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/client.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/env.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/error.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/fallback.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/lib.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/log.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/models.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/provider.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/providers/anthropic.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/providers/gemini.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/providers/openai.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/providers/xai.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/proxy/convert.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/proxy/error.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/proxy/handlers.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/proxy/mod.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/proxy/ratelimit.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/src/proxy/types.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/integration.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/integration_fallback.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/integration_gemini.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/integration_gemini_tools.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/integration_long_context.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/integration_multimodel.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/integration_proxy.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/integration_thinking.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/integration_tool_roundtrip.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/integration_vision.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_anthropic.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_fallback.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_fast_mode.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_gemini.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_log.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_models.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_multimodel.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_openai.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_proxy.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_proxy_convert.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_sse.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_tools.rs +0 -0
- {llmshim-0.2.3 → llmshim-0.3.0}/tests/unit_xai.rs +0 -0
|
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
|
4
4
|
|
|
5
5
|
## What is llmshim
|
|
6
6
|
|
|
7
|
-
A pure Rust LLM API translation layer. Takes OpenAI-format JSON requests, translates them to provider-native formats (and back), with zero infrastructure requirements. Supports OpenAI (Responses API), Anthropic, Google Gemini, and
|
|
7
|
+
A pure Rust LLM API translation layer. Takes OpenAI-format JSON requests, translates them to provider-native formats (and back), with zero infrastructure requirements. Supports OpenAI (Responses API), Anthropic, Google Gemini, xAI, and OpenRouter (an OpenAI Chat Completions-compatible aggregator). Includes an interactive CLI chat with streaming, reasoning, and mid-conversation model switching.
|
|
8
8
|
|
|
9
9
|
**Published on crates.io as `llmshim`** — https://crates.io/crates/llmshim
|
|
10
10
|
|
|
@@ -16,6 +16,7 @@ This is a public crate on crates.io. Do NOT make breaking changes to `pub` items
|
|
|
16
16
|
- **Anthropic:** `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-haiku-4-5-20251001`
|
|
17
17
|
- **Gemini:** `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3-flash-preview`
|
|
18
18
|
- **xAI:** `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-beta-0309`, `grok-4.20-beta-0309-reasoning`, `grok-4.20-beta-0309-non-reasoning`
|
|
19
|
+
- **OpenRouter:** not enumerated (huge/dynamic catalog) — any `openrouter/<vendor>/<model>` slug routes through, e.g. `openrouter/anthropic/claude-sonnet-4.5`.
|
|
19
20
|
|
|
20
21
|
## Build & Test
|
|
21
22
|
|
|
@@ -30,13 +31,15 @@ cargo run # interactive CLI chat
|
|
|
30
31
|
cargo run --features proxy -- proxy # proxy server on :3000
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
API keys: `~/.llmshim/config.toml` (via `llmshim configure`) or env vars `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `XAI_API_KEY`. Precedence: env vars > config file.
|
|
34
|
+
API keys: `~/.llmshim/config.toml` (via `llmshim configure`) or env vars `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `XAI_API_KEY`, `OPENROUTER_API_KEY`. Precedence: env vars > config file.
|
|
34
35
|
|
|
35
36
|
## Architecture
|
|
36
37
|
|
|
37
38
|
### Value-based transforms, no canonical struct
|
|
38
39
|
|
|
39
|
-
Requests flow as `serde_json::Value`. Each provider's transform takes raw JSON and maps only what it understands. Provider-specific features use `x-anthropic`, `x-gemini` namespaces.
|
|
40
|
+
Requests flow as `serde_json::Value`. Each provider's transform takes raw JSON and maps only what it understands. Provider-specific features use `x-anthropic`, `x-gemini`, `x-openrouter` namespaces.
|
|
41
|
+
|
|
42
|
+
**OpenRouter is the one passthrough provider.** Every other provider translates the OpenAI-format input *away* to a native dialect; OpenRouter (`src/providers/openrouter.rs`) *is* OpenAI Chat Completions, so its transforms are near-identity — messages, tools, vision (`image_url`), and `response_format` are forwarded unchanged; `reasoning_effort` maps 1:1 to OpenRouter's `reasoning:{effort}` (its effort vocabulary is a superset, so no clamping); `message.reasoning` is normalized to `reasoning_content` on responses. OpenRouter models are **not enumerated** in `src/models.rs` (the catalog is huge and dynamic) — any `openrouter/<vendor>/<model>` slug routes through. `x-openrouter` carries OpenRouter-only controls (`provider`, `models`, `transforms`, `route`, native `reasoning`; plus `http_referer`/`x_title` which become headers). The `middle-out` transform is disabled by default for faithful passthrough. Uses `image_url` (Chat Completions) vision via `vision::to_openai_chat`.
|
|
40
43
|
|
|
41
44
|
### Request flow
|
|
42
45
|
|
|
@@ -54,7 +57,7 @@ Every provider implements: `transform_request`, `transform_response`, `transform
|
|
|
54
57
|
|
|
55
58
|
### Router (`src/router.rs`)
|
|
56
59
|
|
|
57
|
-
Parses `"provider/model"` strings. Auto-infers provider from prefix (`gpt*`/`o*` → openai, `claude*` → anthropic, `gemini*` → gemini, `grok*` → xai)
|
|
60
|
+
Parses `"provider/model"` strings by splitting on the **first** `/` only, so an OpenRouter slug's internal slash survives (`openrouter/anthropic/claude-sonnet-4.5` → provider `openrouter`, model `anthropic/claude-sonnet-4.5`). Auto-infers provider from prefix (`gpt*`/`o*` → openai, `claude*` → anthropic, `gemini*` → gemini, `grok*` → xai); **OpenRouter has no prefix inference** — its slugs collide with everyone's, so it must be addressed explicitly as `openrouter/…`. Supports aliases. `Router::from_env()` reads API key env vars.
|
|
58
61
|
|
|
59
62
|
### HTTP Client (`src/client.rs`)
|
|
60
63
|
|
|
@@ -90,6 +93,7 @@ llmshim accepts tools in OpenAI Chat Completions format (nested `function` objec
|
|
|
90
93
|
- **OpenAI (Responses API):** Tool definitions flattened from `{"type": "function", "function": {"name": ..., "parameters": ...}}` to `{"type": "function", "name": ..., "parameters": ...}`. Assistant messages with `tool_calls` → `function_call` items. `role: "tool"` messages → `function_call_output` items. Streaming function call events (`response.output_item.added`, `response.function_call_arguments.delta`) translated to Chat Completions chunk format.
|
|
91
94
|
- **Anthropic:** Tools translated to `{"name": ..., "description": ..., "input_schema": ...}` format. Tool results translated to Anthropic's `tool_result` content blocks.
|
|
92
95
|
- **xAI:** Same flat format as OpenAI Responses API — `translate_tools()` flattens nested format.
|
|
96
|
+
- **OpenRouter:** No translation — it accepts the Chat Completions nested `{"type":"function","function":{…}}` format directly, so `tools`/`tool_choice`/`tool_calls` pass through unchanged.
|
|
93
97
|
- **Gemini:** Tools wrapped in `functionDeclarations`. Tool results translated to `functionResponse` format.
|
|
94
98
|
|
|
95
99
|
### CLI (`src/main.rs`)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# llmshim
|
|
2
2
|
|
|
3
|
-
A blazing-fast LLM API translation layer written in **pure Rust**. One request format, every provider — OpenAI, Anthropic, Google Gemini, and
|
|
3
|
+
A blazing-fast LLM API translation layer written in **pure Rust**. One request format, every provider — OpenAI, Anthropic, Google Gemini, xAI, and OpenRouter.
|
|
4
4
|
|
|
5
5
|
Send an OpenAI-style request, pick any model, and llmshim translates it to that provider's native API (and translates the response back). Switch providers by changing one string.
|
|
6
6
|
|
|
@@ -47,8 +47,15 @@ export OPENAI_API_KEY=sk-...
|
|
|
47
47
|
export ANTHROPIC_API_KEY=sk-ant-...
|
|
48
48
|
export GEMINI_API_KEY=AIza...
|
|
49
49
|
export XAI_API_KEY=xai-...
|
|
50
|
+
export OPENROUTER_API_KEY=sk-or-...
|
|
50
51
|
```
|
|
51
52
|
|
|
53
|
+
Reach any model through [OpenRouter](https://openrouter.ai) by addressing it as
|
|
54
|
+
`openrouter/<vendor>/<model>` (e.g. `openrouter/anthropic/claude-sonnet-4.5`).
|
|
55
|
+
OpenRouter is OpenAI Chat Completions-compatible, so tools, vision, streaming,
|
|
56
|
+
and `reasoning_effort` all pass through; OpenRouter-only controls (provider
|
|
57
|
+
routing, model fallbacks, transforms) go under an `x-openrouter` key.
|
|
58
|
+
|
|
52
59
|
Or persist them to the config file (used by all three surfaces):
|
|
53
60
|
|
|
54
61
|
```bash
|
|
@@ -12,6 +12,7 @@ set. Therefore **environment variables take precedence over the file**.
|
|
|
12
12
|
| Anthropic | `ANTHROPIC_API_KEY` | `keys.anthropic` |
|
|
13
13
|
| Google Gemini | `GEMINI_API_KEY` | `keys.gemini` |
|
|
14
14
|
| xAI | `XAI_API_KEY` | `keys.xai` |
|
|
15
|
+
| OpenRouter | `OPENROUTER_API_KEY` | `keys.openrouter` |
|
|
15
16
|
|
|
16
17
|
The config file shape is:
|
|
17
18
|
|
|
@@ -119,6 +119,13 @@ provider with `arbitrary-model-id` unchanged. A bare, unregistered model name
|
|
|
119
119
|
works only when its prefix identifies a provider (`gpt`, `o1`, `o3`, `o4`,
|
|
120
120
|
`claude`, `gemini`, or `grok`).
|
|
121
121
|
|
|
122
|
+
**OpenRouter** is intentionally not enumerated above — its catalog is large and
|
|
123
|
+
dynamic. Any `openrouter/<vendor>/<model>` slug routes through (e.g.
|
|
124
|
+
`openrouter/anthropic/claude-sonnet-4.5`, `openrouter/meta-llama/llama-3.1-70b-instruct:nitro`);
|
|
125
|
+
the slug's internal slash and `:variant` suffix are preserved. Because its
|
|
126
|
+
slugs collide with other providers' prefixes, OpenRouter has no bare-model
|
|
127
|
+
inference — always address it explicitly as `openrouter/…`.
|
|
128
|
+
|
|
122
129
|
Rust applications can also define one-level Router aliases with
|
|
123
130
|
`Router::alias`. Those aliases are not part of the static registry and are not
|
|
124
131
|
configured by the stock CLI or proxy. See [Models and the Router](../concepts/routing.md).
|
|
@@ -9,6 +9,7 @@ different native API and translates only the fields that API understands.
|
|
|
9
9
|
| Anthropic | Messages API | `claude*` | `x-anthropic` |
|
|
10
10
|
| Google Gemini | `generateContent` / `streamGenerateContent` | `gemini*` | `x-gemini` |
|
|
11
11
|
| xAI | Responses API | `grok*` | none |
|
|
12
|
+
| OpenRouter | Chat Completions (aggregator) | none — address as `openrouter/<vendor>/<model>` | `x-openrouter` |
|
|
12
13
|
|
|
13
14
|
An explicit address such as `anthropic/claude-sonnet-5` avoids inference.
|
|
14
15
|
The named provider must be registered in the Router—that normally means its
|
|
@@ -22,6 +23,7 @@ API key is configured.
|
|
|
22
23
|
| Anthropic | Messages become Anthropic content blocks; tools use `input_schema`, `tool_use`, and `tool_result` | `max_tokens` defaults to 8192 when absent; supported models receive the 1M-context beta by default; `x-anthropic.extra_betas` appends beta headers and `disable_1m_context` suppresses that automatic header |
|
|
23
24
|
| Gemini | Messages become `contents`; tools use `functionDeclarations`, `functionCall`, and `functionResponse` | Base64 images become `inline_data`, but a remote image URL becomes a text placeholder because Gemini cannot consume it directly; `x-gemini.thinkingConfig` replaces mapped thinking configuration |
|
|
24
25
|
| xAI | System/developer text becomes Responses `instructions`; tools are flattened like OpenAI Responses | Unified reasoning becomes `reasoning: {effort}` where the model accepts it; grok-4.20 reasoning is encoded in the model name; there is no `x-xai` namespace |
|
|
26
|
+
| OpenRouter | Passthrough — messages, tools, `image_url` vision, and `response_format` are already Chat Completions and forwarded unchanged | `reasoning_effort` maps 1:1 to OpenRouter's `reasoning:{effort}` (superset vocabulary, no clamping); `message.reasoning` is normalized to `reasoning_content`; the `middle-out` transform is disabled by default; `x-openrouter` carries `provider`/`models`/`transforms`/`route`/native `reasoning` (and `http_referer`/`x_title` headers) |
|
|
25
27
|
|
|
26
28
|
OpenAI and xAI do not receive `temperature`, `top_p`, `top_k`, or `stop` from
|
|
27
29
|
the portable top-level request. Anthropic and Gemini do. This is the
|
|
@@ -29,6 +29,7 @@ renamed or reshaped for the selected provider; unsupported controls are omitted.
|
|
|
29
29
|
| `x-openai` | object | Native passthrough | Fields copied to an OpenAI Responses request |
|
|
30
30
|
| `x-anthropic` | object | Native passthrough/control | Anthropic body fields plus `extra_betas` and `disable_1m_context` controls |
|
|
31
31
|
| `x-gemini` | object | Native passthrough | Gemini body fields; `thinkingConfig` goes under `generationConfig` |
|
|
32
|
+
| `x-openrouter` | object | Native passthrough/control | OpenRouter body fields (`provider`, `models`, `transforms`, `route`, native `reasoning`); `http_referer`/`x_title` become request headers |
|
|
32
33
|
|
|
33
34
|
There is no `x-xai` namespace. OpenAI additionally recognizes `store`,
|
|
34
35
|
`prompt_cache_key`, `prompt_cache_retention`, `safety_identifier`, and `speed`.
|
|
@@ -25,6 +25,8 @@ pub struct Keys {
|
|
|
25
25
|
pub anthropic: Option<String>,
|
|
26
26
|
pub gemini: Option<String>,
|
|
27
27
|
pub xai: Option<String>,
|
|
28
|
+
#[serde(default)]
|
|
29
|
+
pub openrouter: Option<String>,
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
#[derive(Debug, Serialize, Deserialize)]
|
|
@@ -93,6 +95,7 @@ pub fn apply_to_env(config: &Config) {
|
|
|
93
95
|
("ANTHROPIC_API_KEY", &config.keys.anthropic),
|
|
94
96
|
("GEMINI_API_KEY", &config.keys.gemini),
|
|
95
97
|
("XAI_API_KEY", &config.keys.xai),
|
|
98
|
+
("OPENROUTER_API_KEY", &config.keys.openrouter),
|
|
96
99
|
];
|
|
97
100
|
for (env_key, value) in mappings {
|
|
98
101
|
if std::env::var(env_key).is_err() {
|
|
@@ -504,6 +504,11 @@ fn cmd_configure() {
|
|
|
504
504
|
cfg.keys.xai = Some(xai);
|
|
505
505
|
}
|
|
506
506
|
|
|
507
|
+
let openrouter = config_prompt("OpenRouter API Key", cfg.keys.openrouter.as_deref());
|
|
508
|
+
if !openrouter.is_empty() {
|
|
509
|
+
cfg.keys.openrouter = Some(openrouter);
|
|
510
|
+
}
|
|
511
|
+
|
|
507
512
|
let host = config_prompt_plain("Proxy host", &cfg.proxy.host);
|
|
508
513
|
if !host.is_empty() {
|
|
509
514
|
cfg.proxy.host = host;
|
|
@@ -535,6 +540,7 @@ fn cmd_set(key: &str, value: &str) {
|
|
|
535
540
|
"anthropic" => cfg.keys.anthropic = Some(value.to_string()),
|
|
536
541
|
"gemini" => cfg.keys.gemini = Some(value.to_string()),
|
|
537
542
|
"xai" => cfg.keys.xai = Some(value.to_string()),
|
|
543
|
+
"openrouter" => cfg.keys.openrouter = Some(value.to_string()),
|
|
538
544
|
"proxy.host" => cfg.proxy.host = value.to_string(),
|
|
539
545
|
"proxy.port" => {
|
|
540
546
|
cfg.proxy.port = value.parse().unwrap_or_else(|_| {
|
|
@@ -544,7 +550,7 @@ fn cmd_set(key: &str, value: &str) {
|
|
|
544
550
|
}
|
|
545
551
|
_ => {
|
|
546
552
|
eprintln!(
|
|
547
|
-
"Unknown key: {}. Valid: openai, anthropic, gemini, xai, proxy.host, proxy.port",
|
|
553
|
+
"Unknown key: {}. Valid: openai, anthropic, gemini, xai, openrouter, proxy.host, proxy.port",
|
|
548
554
|
key
|
|
549
555
|
);
|
|
550
556
|
std::process::exit(1);
|
|
@@ -574,6 +580,7 @@ fn cmd_get(key: &str) {
|
|
|
574
580
|
"anthropic" => cfg.keys.anthropic.as_deref().map(mask_key),
|
|
575
581
|
"gemini" => cfg.keys.gemini.as_deref().map(mask_key),
|
|
576
582
|
"xai" => cfg.keys.xai.as_deref().map(mask_key),
|
|
583
|
+
"openrouter" => cfg.keys.openrouter.as_deref().map(mask_key),
|
|
577
584
|
"proxy.host" => Some(cfg.proxy.host.clone()),
|
|
578
585
|
"proxy.port" => Some(cfg.proxy.port.to_string()),
|
|
579
586
|
_ => {
|
|
@@ -593,6 +600,7 @@ fn cmd_list() {
|
|
|
593
600
|
("anthropic", &cfg.keys.anthropic),
|
|
594
601
|
("gemini", &cfg.keys.gemini),
|
|
595
602
|
("xai", &cfg.keys.xai),
|
|
603
|
+
("openrouter", &cfg.keys.openrouter),
|
|
596
604
|
] {
|
|
597
605
|
let display = match value {
|
|
598
606
|
Some(v) if !v.is_empty() => mask_key(v),
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
use crate::error::{Result, ShimError};
|
|
2
|
+
use crate::provider::{Provider, ProviderRequest};
|
|
3
|
+
use crate::vision;
|
|
4
|
+
use serde_json::{json, Value};
|
|
5
|
+
|
|
6
|
+
/// OpenRouter (https://openrouter.ai) — an OpenAI Chat Completions-compatible
|
|
7
|
+
/// aggregator. Unlike the other providers, which translate *away* from Chat
|
|
8
|
+
/// Completions to a native dialect, OpenRouter *is* Chat Completions, so this is
|
|
9
|
+
/// a near-passthrough: messages, tools, vision, and `response_format` are
|
|
10
|
+
/// already in the target shape and are forwarded unchanged. Model slugs
|
|
11
|
+
/// (`vendor/model`, e.g. `anthropic/claude-sonnet-4.5`) contain a slash and
|
|
12
|
+
/// arrive here intact because the router splits only on the first `/`.
|
|
13
|
+
pub struct OpenRouter {
|
|
14
|
+
pub api_key: String,
|
|
15
|
+
pub base_url: String,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
impl OpenRouter {
|
|
19
|
+
pub fn new(api_key: String) -> Self {
|
|
20
|
+
Self {
|
|
21
|
+
api_key,
|
|
22
|
+
base_url: "https://openrouter.ai/api/v1".to_string(),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pub fn with_base_url(mut self, url: String) -> Self {
|
|
27
|
+
self.base_url = url;
|
|
28
|
+
self
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// OpenRouter's reasoning-effort vocabulary is a superset of the unified one, so
|
|
33
|
+
/// the mapping is 1:1 (no per-model clamp — OpenRouter enforces the underlying
|
|
34
|
+
/// model's limits itself). `reasoning_mode: "pro"` bumps one tier, mirroring the
|
|
35
|
+
/// other providers; explicit `none` always wins.
|
|
36
|
+
fn normalize_openrouter_effort(effort: &str, pro: bool) -> &'static str {
|
|
37
|
+
let base = match effort {
|
|
38
|
+
"none" => "none",
|
|
39
|
+
"minimal" => "minimal",
|
|
40
|
+
"low" => "low",
|
|
41
|
+
"medium" => "medium",
|
|
42
|
+
"high" => "high",
|
|
43
|
+
"xhigh" => "xhigh",
|
|
44
|
+
"max" => "max",
|
|
45
|
+
_ => "medium",
|
|
46
|
+
};
|
|
47
|
+
if !pro {
|
|
48
|
+
return base;
|
|
49
|
+
}
|
|
50
|
+
match base {
|
|
51
|
+
"none" => "none",
|
|
52
|
+
"minimal" => "low",
|
|
53
|
+
"low" => "medium",
|
|
54
|
+
"medium" => "high",
|
|
55
|
+
"high" => "xhigh",
|
|
56
|
+
_ => "max", // xhigh, max
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Sanitize messages for OpenRouter (OpenAI Chat Completions native). Strip
|
|
61
|
+
/// llmshim-normalized / foreign-provider fields so multi-model conversations
|
|
62
|
+
/// don't leak them, and normalize vision blocks to OpenAI form. Messages,
|
|
63
|
+
/// `tool_calls`, and `role: "tool"` all stay in Chat Completions shape.
|
|
64
|
+
fn sanitize_messages(messages: &[Value]) -> Vec<Value> {
|
|
65
|
+
messages
|
|
66
|
+
.iter()
|
|
67
|
+
.map(|msg| {
|
|
68
|
+
let mut out = msg.clone();
|
|
69
|
+
if let Some(obj) = out.as_object_mut() {
|
|
70
|
+
obj.remove("reasoning_content"); // llmshim-normalized; OpenRouter uses reasoning_details
|
|
71
|
+
obj.remove("reasoning_signature"); // opaque Anthropic token — never forward
|
|
72
|
+
obj.remove("redacted_reasoning_content"); // opaque Anthropic token — never forward
|
|
73
|
+
obj.remove("annotations");
|
|
74
|
+
obj.remove("refusal");
|
|
75
|
+
}
|
|
76
|
+
if let Some(content) = out.get("content").cloned() {
|
|
77
|
+
if content.is_array() {
|
|
78
|
+
// Normalize any image format to Chat Completions `image_url`,
|
|
79
|
+
// and any Responses-style `input_text` back to `text`.
|
|
80
|
+
let translated =
|
|
81
|
+
vision::translate_content_blocks(&content, vision::to_openai_chat);
|
|
82
|
+
out["content"] = normalize_text_blocks_to_chat(&translated);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
out
|
|
86
|
+
})
|
|
87
|
+
.collect()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Convert Responses-style `input_text` blocks back to Chat Completions `text`.
|
|
91
|
+
/// (llmshim's canonical input already uses `text`; this only matters when a
|
|
92
|
+
/// caller mixes in Responses-format blocks.)
|
|
93
|
+
fn normalize_text_blocks_to_chat(content: &Value) -> Value {
|
|
94
|
+
match content {
|
|
95
|
+
Value::Array(blocks) => Value::Array(
|
|
96
|
+
blocks
|
|
97
|
+
.iter()
|
|
98
|
+
.map(|b| {
|
|
99
|
+
if b.get("type").and_then(|t| t.as_str()) == Some("input_text") {
|
|
100
|
+
let mut out = b.clone();
|
|
101
|
+
out["type"] = json!("text");
|
|
102
|
+
out
|
|
103
|
+
} else {
|
|
104
|
+
b.clone()
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
.collect(),
|
|
108
|
+
),
|
|
109
|
+
_ => content.clone(),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
impl Provider for OpenRouter {
|
|
114
|
+
fn name(&self) -> &str {
|
|
115
|
+
"openrouter"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fn transform_request(&self, model: &str, request: &Value) -> Result<ProviderRequest> {
|
|
119
|
+
let obj = request.as_object().ok_or(ShimError::MissingModel)?;
|
|
120
|
+
let messages = obj
|
|
121
|
+
.get("messages")
|
|
122
|
+
.and_then(|m| m.as_array())
|
|
123
|
+
.ok_or(ShimError::MissingModel)?;
|
|
124
|
+
|
|
125
|
+
let mut body = json!({
|
|
126
|
+
"model": model,
|
|
127
|
+
"messages": sanitize_messages(messages),
|
|
128
|
+
});
|
|
129
|
+
let body_obj = body.as_object_mut().unwrap();
|
|
130
|
+
|
|
131
|
+
// Standard Chat Completions params — forwarded unchanged (OpenRouter is
|
|
132
|
+
// the target format, so tools/response_format need no translation).
|
|
133
|
+
for key in [
|
|
134
|
+
"max_tokens",
|
|
135
|
+
"max_completion_tokens",
|
|
136
|
+
"temperature",
|
|
137
|
+
"top_p",
|
|
138
|
+
"top_k",
|
|
139
|
+
"frequency_penalty",
|
|
140
|
+
"presence_penalty",
|
|
141
|
+
"stop",
|
|
142
|
+
"seed",
|
|
143
|
+
"stream",
|
|
144
|
+
"stream_options",
|
|
145
|
+
"tools",
|
|
146
|
+
"tool_choice",
|
|
147
|
+
"parallel_tool_calls",
|
|
148
|
+
"response_format",
|
|
149
|
+
"logprobs",
|
|
150
|
+
"top_logprobs",
|
|
151
|
+
"n",
|
|
152
|
+
] {
|
|
153
|
+
if let Some(v) = obj.get(key) {
|
|
154
|
+
body_obj.insert(key.to_string(), v.clone());
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Unified reasoning -> OpenRouter `reasoning: {effort}`. A native
|
|
159
|
+
// `x-openrouter.reasoning` object takes precedence and is copied below.
|
|
160
|
+
let has_native_reasoning = obj
|
|
161
|
+
.get("x-openrouter")
|
|
162
|
+
.and_then(|x| x.get("reasoning"))
|
|
163
|
+
.is_some();
|
|
164
|
+
if !has_native_reasoning {
|
|
165
|
+
if let Some(effort) = obj.get("reasoning_effort").and_then(|e| e.as_str()) {
|
|
166
|
+
let pro = obj.get("reasoning_mode").and_then(|m| m.as_str()) == Some("pro");
|
|
167
|
+
let effort = normalize_openrouter_effort(effort, pro);
|
|
168
|
+
body_obj.insert("reasoning".to_string(), json!({ "effort": effort }));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let mut headers = vec![
|
|
173
|
+
(
|
|
174
|
+
"Authorization".to_string(),
|
|
175
|
+
format!("Bearer {}", self.api_key),
|
|
176
|
+
),
|
|
177
|
+
("Content-Type".to_string(), "application/json".to_string()),
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
// x-openrouter namespace: OpenRouter-only controls. Body params
|
|
181
|
+
// (provider, models, transforms, route, reasoning) are copied into the
|
|
182
|
+
// body; the two attribution headers are copied to headers, not the body.
|
|
183
|
+
if let Some(ext) = obj.get("x-openrouter").and_then(|e| e.as_object()) {
|
|
184
|
+
for (k, v) in ext {
|
|
185
|
+
match k.as_str() {
|
|
186
|
+
"http_referer" | "HTTP-Referer" => {
|
|
187
|
+
if let Some(s) = v.as_str() {
|
|
188
|
+
headers.push(("HTTP-Referer".to_string(), s.to_string()));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
"x_title" | "X-Title" => {
|
|
192
|
+
if let Some(s) = v.as_str() {
|
|
193
|
+
headers.push(("X-Title".to_string(), s.to_string()));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
_ => {
|
|
197
|
+
body_obj.insert(k.clone(), v.clone());
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// OpenRouter enables the `middle-out` transform by default, which
|
|
204
|
+
// silently drops middle messages to fit the context window. For a
|
|
205
|
+
// faithful passthrough we disable it; callers opt back in via
|
|
206
|
+
// `x-openrouter.transforms`.
|
|
207
|
+
if !body_obj.contains_key("transforms") {
|
|
208
|
+
body_obj.insert("transforms".to_string(), json!([]));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let url = format!("{}/chat/completions", self.base_url);
|
|
212
|
+
Ok(ProviderRequest { url, headers, body })
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
fn transform_response(&self, _model: &str, mut response: Value) -> Result<Value> {
|
|
216
|
+
// Non-stream errors usually surface via HTTP status, but a body-level
|
|
217
|
+
// `error` object can also appear — turn it into a ProviderError.
|
|
218
|
+
if let Some(err) = response.get("error") {
|
|
219
|
+
if !err.is_null() {
|
|
220
|
+
let message = err
|
|
221
|
+
.get("message")
|
|
222
|
+
.and_then(|m| m.as_str())
|
|
223
|
+
.unwrap_or("unknown error")
|
|
224
|
+
.to_string();
|
|
225
|
+
let status = err.get("code").and_then(|c| c.as_u64()).unwrap_or(400) as u16;
|
|
226
|
+
return Err(ShimError::ProviderError {
|
|
227
|
+
status,
|
|
228
|
+
body: message,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The response is already Chat Completions-shaped. Normalize OpenRouter's
|
|
234
|
+
// `message.reasoning` into llmshim's `reasoning_content` convention.
|
|
235
|
+
if let Some(choices) = response.get_mut("choices").and_then(|c| c.as_array_mut()) {
|
|
236
|
+
for choice in choices {
|
|
237
|
+
if let Some(msg) = choice.get_mut("message").and_then(|m| m.as_object_mut()) {
|
|
238
|
+
if !msg.contains_key("reasoning_content") {
|
|
239
|
+
if let Some(r) = msg
|
|
240
|
+
.get("reasoning")
|
|
241
|
+
.and_then(|r| r.as_str())
|
|
242
|
+
.filter(|s| !s.is_empty())
|
|
243
|
+
.map(str::to_string)
|
|
244
|
+
{
|
|
245
|
+
msg.insert("reasoning_content".to_string(), json!(r));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
Ok(response)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
fn transform_stream_chunk(&self, _model: &str, chunk: &str) -> Result<Option<String>> {
|
|
256
|
+
// OpenRouter's SSE chunks are OpenAI-delta shaped. Parse, normalize the
|
|
257
|
+
// reasoning delta, and forward. Unparseable payloads (e.g. stray
|
|
258
|
+
// keepalive text) are skipped. `data: [DONE]` and `:`-comment keepalives
|
|
259
|
+
// are already handled by the client's SSE reader.
|
|
260
|
+
let mut parsed: Value = match serde_json::from_str(chunk) {
|
|
261
|
+
Ok(v) => v,
|
|
262
|
+
Err(_) => return Ok(None),
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
if let Some(choices) = parsed.get_mut("choices").and_then(|c| c.as_array_mut()) {
|
|
266
|
+
for choice in choices {
|
|
267
|
+
if let Some(delta) = choice.get_mut("delta").and_then(|d| d.as_object_mut()) {
|
|
268
|
+
if !delta.contains_key("reasoning_content") {
|
|
269
|
+
if let Some(r) = delta
|
|
270
|
+
.get("reasoning")
|
|
271
|
+
.and_then(|r| r.as_str())
|
|
272
|
+
.map(str::to_string)
|
|
273
|
+
{
|
|
274
|
+
delta.insert("reasoning_content".to_string(), json!(r));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
Ok(Some(serde_json::to_string(&parsed)?))
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -3,6 +3,7 @@ use crate::provider::Provider;
|
|
|
3
3
|
use crate::providers::anthropic::Anthropic;
|
|
4
4
|
use crate::providers::gemini::Gemini;
|
|
5
5
|
use crate::providers::openai::OpenAi;
|
|
6
|
+
use crate::providers::openrouter::OpenRouter;
|
|
6
7
|
use crate::providers::xai::Xai;
|
|
7
8
|
use std::collections::HashMap;
|
|
8
9
|
|
|
@@ -93,6 +94,9 @@ impl Router {
|
|
|
93
94
|
if let Ok(key) = std::env::var("XAI_API_KEY") {
|
|
94
95
|
router = router.register("xai", Box::new(Xai::new(key)));
|
|
95
96
|
}
|
|
97
|
+
if let Ok(key) = std::env::var("OPENROUTER_API_KEY") {
|
|
98
|
+
router = router.register("openrouter", Box::new(OpenRouter::new(key)));
|
|
99
|
+
}
|
|
96
100
|
|
|
97
101
|
router
|
|
98
102
|
}
|
|
@@ -167,6 +167,50 @@ pub fn to_openai(block: &Value) -> Option<Value> {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/// Translate an image block to OpenAI **Chat Completions** format
|
|
171
|
+
/// (`{"type":"image_url","image_url":{"url":...}}`) — the shape OpenAI-compatible
|
|
172
|
+
/// aggregators like OpenRouter expect. (`to_openai` targets the Responses API's
|
|
173
|
+
/// `input_image` string form instead.)
|
|
174
|
+
pub fn to_openai_chat(block: &Value) -> Option<Value> {
|
|
175
|
+
let block_type = block.get("type").and_then(|t| t.as_str())?;
|
|
176
|
+
|
|
177
|
+
match block_type {
|
|
178
|
+
// Already Chat Completions format.
|
|
179
|
+
"image_url" => Some(block.clone()),
|
|
180
|
+
|
|
181
|
+
// OpenAI Responses API `input_image` (image_url is a bare string).
|
|
182
|
+
"input_image" => {
|
|
183
|
+
let url = block.get("image_url").and_then(|u| u.as_str())?;
|
|
184
|
+
Some(json!({ "type": "image_url", "image_url": { "url": url } }))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Anthropic format → Chat Completions.
|
|
188
|
+
"image" => {
|
|
189
|
+
let source = block.get("source")?;
|
|
190
|
+
match source.get("type").and_then(|t| t.as_str())? {
|
|
191
|
+
"base64" => {
|
|
192
|
+
let media_type = source
|
|
193
|
+
.get("media_type")
|
|
194
|
+
.and_then(|m| m.as_str())
|
|
195
|
+
.unwrap_or("image/jpeg");
|
|
196
|
+
let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
|
|
197
|
+
Some(json!({
|
|
198
|
+
"type": "image_url",
|
|
199
|
+
"image_url": { "url": format!("data:{};base64,{}", media_type, data) }
|
|
200
|
+
}))
|
|
201
|
+
}
|
|
202
|
+
"url" => {
|
|
203
|
+
let url = source.get("url").and_then(|u| u.as_str())?;
|
|
204
|
+
Some(json!({ "type": "image_url", "image_url": { "url": url } }))
|
|
205
|
+
}
|
|
206
|
+
_ => None,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_ => None,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
170
214
|
/// Translate all content blocks in a message's content array.
|
|
171
215
|
/// If content is a string, returns it unchanged.
|
|
172
216
|
/// If content is an array, translates image blocks using the given translator.
|