llmshim 0.3.0__tar.gz → 0.3.2__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.3.0 → llmshim-0.3.2}/CLAUDE.md +9 -5
- {llmshim-0.3.0 → llmshim-0.3.2}/Cargo.lock +1 -1
- {llmshim-0.3.0 → llmshim-0.3.2}/Cargo.toml +1 -1
- {llmshim-0.3.0 → llmshim-0.3.2}/PKG-INFO +2 -2
- {llmshim-0.3.0 → llmshim-0.3.2}/README.md +14 -2
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/guides/reasoning.md +2 -2
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/reference/configuration.md +2 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/reference/models.md +3 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/reference/providers.md +3 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/reference/request-fields.md +1 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/main.rs +1 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/models.rs +9 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/providers/mod.rs +1 -0
- llmshim-0.3.2/src/providers/openai_compat.rs +196 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/providers/xai.rs +7 -4
- {llmshim-0.3.0 → llmshim-0.3.2}/src/router.rs +17 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/vision.rs +24 -0
- llmshim-0.3.2/tests/integration_sglang.rs +99 -0
- llmshim-0.3.2/tests/integration_xai.rs +72 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_models.rs +2 -1
- llmshim-0.3.2/tests/unit_openai_compat.rs +204 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_router.rs +11 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_xai.rs +23 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/.github/workflows/pages.yml +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/.github/workflows/release.yml +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/.gitignore +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/CODE_OF_CONDUCT.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/CONTRIBUTING.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/LICENSE-APACHE +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/LICENSE-MIT +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/NOTICE +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/SECURITY.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/benchmarks/bench.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/benchmarks/bench_python.py +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/benchmarks/loadtest.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/.gitignore +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/book.toml +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/mermaid-init.js +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/mermaid.min.js +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/SUMMARY.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/concepts/contracts.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/concepts/conversations.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/concepts/portability.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/concepts/routing.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/concepts/translation-flow.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/guides/fallbacks.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/guides/images.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/guides/native-controls.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/guides/streaming.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/guides/tools.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/introduction.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/proxy/deployment.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/proxy/http-api.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/proxy/scaling.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/reference/api.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/reference/cli.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/reference/errors.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/reference/surfaces.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/start/choose.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/start/cli.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/start/clients.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/start/configure.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/start/proxy.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/docs/src/start/rust.md +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/examples/chat.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/examples/stream.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/llmshim/__init__.py +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/llmshim/_client.py +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/llmshim/_server.py +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/llmshim/types.py +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/pyproject.toml +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/client.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/config.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/env.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/error.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/fallback.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/lib.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/log.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/provider.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/providers/anthropic.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/providers/gemini.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/providers/openai.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/providers/openrouter.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/proxy/convert.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/proxy/error.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/proxy/handlers.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/proxy/mod.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/proxy/ratelimit.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/src/proxy/types.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/integration.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/integration_fallback.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/integration_gemini.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/integration_gemini_tools.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/integration_long_context.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/integration_multimodel.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/integration_openrouter.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/integration_proxy.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/integration_thinking.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/integration_tool_roundtrip.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/integration_vision.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_anthropic.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_fallback.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_fast_mode.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_gemini.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_log.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_multimodel.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_openai.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_openrouter.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_proxy.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_proxy_convert.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_sse.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_tools.rs +0 -0
- {llmshim-0.3.0 → llmshim-0.3.2}/tests/unit_vision.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, xAI,
|
|
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, OpenRouter (an OpenAI Chat Completions-compatible aggregator), and self-hosted **vLLM** / **SGLang** servers (OpenAI Chat Completions-compatible, local or remote). 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
|
|
|
@@ -15,8 +15,9 @@ This is a public crate on crates.io. Do NOT make breaking changes to `pub` items
|
|
|
15
15
|
- **OpenAI:** `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`, `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano`
|
|
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
|
-
- **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`
|
|
18
|
+
- **xAI:** `grok-4.6`, `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
19
|
- **OpenRouter:** not enumerated (huge/dynamic catalog) — any `openrouter/<vendor>/<model>` slug routes through, e.g. `openrouter/anthropic/claude-sonnet-4.5`.
|
|
20
|
+
- **vLLM / SGLang:** not enumerated (self-hosted) — any `vllm/<served-model>` or `sglang/<served-model>` routes through to the configured server, e.g. `sglang/Qwen/Qwen3.6-35B-A3B-FP8`.
|
|
20
21
|
|
|
21
22
|
## Build & Test
|
|
22
23
|
|
|
@@ -31,13 +32,15 @@ cargo run # interactive CLI chat
|
|
|
31
32
|
cargo run --features proxy -- proxy # proxy server on :3000
|
|
32
33
|
```
|
|
33
34
|
|
|
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.
|
|
35
|
+
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. Self-hosted servers are configured by **base URL** instead of a key: `VLLM_BASE_URL` / `SGLANG_BASE_URL` (each with an optional `VLLM_API_KEY` / `SGLANG_API_KEY`); the provider registers only when its base URL is set. Local vs remote is just the URL value.
|
|
35
36
|
|
|
36
37
|
## Architecture
|
|
37
38
|
|
|
38
39
|
### Value-based transforms, no canonical struct
|
|
39
40
|
|
|
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
|
+
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`, `x-vllm`, `x-sglang` namespaces.
|
|
42
|
+
|
|
43
|
+
**Self-hosted passthrough providers (vLLM / SGLang).** `src/providers/openai_compat.rs` is one generic OpenAI Chat Completions passthrough backing both `vllm` and `sglang` (`OpenAiCompatible::new(name, base_url, api_key: Option)`, registered per env base URL). Two things differ from the hosted providers: the **base URL is configuration** (local `http://localhost:8000/v1` vs remote `https://host/v1`), and **auth is optional** (self-hosted servers are unauthenticated unless launched with `--api-key`, so the `Authorization` header is sent only when a key is set). Passthrough transforms; `reasoning`/`reasoning_content` normalized to `reasoning_content` (vLLM is migrating the field name); `reasoning_effort` forwarded as-is (honored per-model, not clamped); server-specific params go under `x-<name>` (`chat_template_kwargs`, `separate_reasoning`, `guided_json`, `top_k`, …). Note: reasoning/tool parsing are **launch-time server flags** (`--reasoning-parser`, `--tool-call-parser`), so a request only gets that behavior if the server was started for it — llmshim can't enable it per request.
|
|
41
44
|
|
|
42
45
|
**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`.
|
|
43
46
|
|
|
@@ -57,7 +60,7 @@ Every provider implements: `transform_request`, `transform_response`, `transform
|
|
|
57
60
|
|
|
58
61
|
### Router (`src/router.rs`)
|
|
59
62
|
|
|
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
|
|
63
|
+
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, vLLM, and SGLang have no prefix inference** — their slugs collide with everyone's, so address them explicitly (`openrouter/…`, `vllm/…`, `sglang/…`); the first-slash split also preserves HF-style served-model slugs (`vllm/meta-llama/Llama-3.1-8B-Instruct`). Supports aliases. `Router::from_env()` reads API-key env vars, plus `VLLM_BASE_URL` / `SGLANG_BASE_URL` (+ optional `*_API_KEY`) for the self-hosted providers.
|
|
61
64
|
|
|
62
65
|
### HTTP Client (`src/client.rs`)
|
|
63
66
|
|
|
@@ -94,6 +97,7 @@ llmshim accepts tools in OpenAI Chat Completions format (nested `function` objec
|
|
|
94
97
|
- **Anthropic:** Tools translated to `{"name": ..., "description": ..., "input_schema": ...}` format. Tool results translated to Anthropic's `tool_result` content blocks.
|
|
95
98
|
- **xAI:** Same flat format as OpenAI Responses API — `translate_tools()` flattens nested format.
|
|
96
99
|
- **OpenRouter:** No translation — it accepts the Chat Completions nested `{"type":"function","function":{…}}` format directly, so `tools`/`tool_choice`/`tool_calls` pass through unchanged.
|
|
100
|
+
- **vLLM / SGLang:** Same as OpenRouter — Chat Completions nested tool format passes through unchanged (the server must be launched with `--tool-call-parser` / `--enable-auto-tool-choice` for tool calls to be parsed).
|
|
97
101
|
- **Gemini:** Tools wrapped in `functionDeclarations`. Tool results translated to `functionResponse` format.
|
|
98
102
|
|
|
99
103
|
### CLI (`src/main.rs`)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: llmshim
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Classifier: Development Status :: 4 - Beta
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -223,7 +223,7 @@ billed provider calls; run it only when you deliberately want to hit real APIs.
|
|
|
223
223
|
| OpenAI | `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`, `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano` |
|
|
224
224
|
| 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` |
|
|
225
225
|
| Gemini | `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3-flash-preview` |
|
|
226
|
-
| 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` |
|
|
226
|
+
| xAI | `grok-4.6`, `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` |
|
|
227
227
|
|
|
228
228
|
Call `llmshim.models()` for the live list filtered to your configured providers.
|
|
229
229
|
|
|
@@ -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, xAI, and
|
|
3
|
+
A blazing-fast LLM API translation layer written in **pure Rust**. One request format, every provider — OpenAI, Anthropic, Google Gemini, xAI, OpenRouter, and self-hosted vLLM / SGLang.
|
|
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
|
|
|
@@ -56,6 +56,18 @@ OpenRouter is OpenAI Chat Completions-compatible, so tools, vision, streaming,
|
|
|
56
56
|
and `reasoning_effort` all pass through; OpenRouter-only controls (provider
|
|
57
57
|
routing, model fallbacks, transforms) go under an `x-openrouter` key.
|
|
58
58
|
|
|
59
|
+
Point at a **self-hosted vLLM or SGLang** server (local or remote) by setting its
|
|
60
|
+
base URL — no key needed unless the server was launched with one:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
export SGLANG_BASE_URL=http://localhost:30000/v1 # or https://your-host/v1
|
|
64
|
+
export VLLM_BASE_URL=http://localhost:8000/v1
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then address the served model as `sglang/<served-model>` or `vllm/<served-model>`
|
|
68
|
+
(e.g. `sglang/Qwen/Qwen3.6-35B-A3B-FP8`). Server-specific knobs go under
|
|
69
|
+
`x-vllm` / `x-sglang`.
|
|
70
|
+
|
|
59
71
|
Or persist them to the config file (used by all three surfaces):
|
|
60
72
|
|
|
61
73
|
```bash
|
|
@@ -353,7 +365,7 @@ Standard library only. Full docs: [`clients/ruby/README.md`](clients/ruby/README
|
|
|
353
365
|
| **OpenAI** | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`, `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano` | Yes (summaries) |
|
|
354
366
|
| **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` | Yes (full thinking) |
|
|
355
367
|
| **Google Gemini** | `gemini-3.5-flash`, `gemini-3.1-pro-preview`, `gemini-3-flash-preview` | Yes (thought summaries) |
|
|
356
|
-
| **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` | No (hidden) |
|
|
368
|
+
| **xAI** | `grok-4.6`, `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` | No (hidden) |
|
|
357
369
|
|
|
358
370
|
Use a bare model name (auto-detected by prefix) or an explicit `provider/model` string.
|
|
359
371
|
|
|
@@ -134,7 +134,7 @@ through `x-gemini.thinkingConfig`.
|
|
|
134
134
|
|
|
135
135
|
xAI receives the nested native shape `reasoning: {effort}`:
|
|
136
136
|
|
|
137
|
-
| unified | grok-4.3 | grok-4.5 | grok-4.20-\*-reasoning / -non-reasoning |
|
|
137
|
+
| unified | grok-4.3 | grok-4.5 / grok-4.6 | grok-4.20-\*-reasoning / -non-reasoning |
|
|
138
138
|
|---|---|---|---|
|
|
139
139
|
| `none` | `none` | **`low`** | omitted |
|
|
140
140
|
| `low` | `low` | `low` | omitted |
|
|
@@ -143,7 +143,7 @@ xAI receives the nested native shape `reasoning: {effort}`:
|
|
|
143
143
|
| `xhigh` | `xhigh` | `xhigh` | omitted |
|
|
144
144
|
| `max` | **`xhigh`** | **`xhigh`** | omitted |
|
|
145
145
|
|
|
146
|
-
grok-4.5 cannot disable reasoning, so `none` clamps to `low`. grok-4.20 models
|
|
146
|
+
grok-4.5 and grok-4.6 cannot disable reasoning, so `none` clamps to `low`. grok-4.20 models
|
|
147
147
|
are name-locked: reasoning on or off is encoded in the model name, and the API
|
|
148
148
|
rejects any reasoning parameter. llmshim therefore omits it for that family.
|
|
149
149
|
|
|
@@ -13,6 +13,8 @@ set. Therefore **environment variables take precedence over the file**.
|
|
|
13
13
|
| Google Gemini | `GEMINI_API_KEY` | `keys.gemini` |
|
|
14
14
|
| xAI | `XAI_API_KEY` | `keys.xai` |
|
|
15
15
|
| OpenRouter | `OPENROUTER_API_KEY` | `keys.openrouter` |
|
|
16
|
+
| vLLM (self-hosted) | `VLLM_BASE_URL` (+ optional `VLLM_API_KEY`) | — (env only) |
|
|
17
|
+
| SGLang (self-hosted) | `SGLANG_BASE_URL` (+ optional `SGLANG_API_KEY`) | — (env only) |
|
|
16
18
|
|
|
17
19
|
The config file shape is:
|
|
18
20
|
|
|
@@ -56,6 +56,7 @@ this table in applications.
|
|
|
56
56
|
|
|
57
57
|
| ID | Display name |
|
|
58
58
|
|---|---|
|
|
59
|
+
| `xai/grok-4.6` | Grok 4.6 |
|
|
59
60
|
| `xai/grok-4.5` | Grok 4.5 |
|
|
60
61
|
| `xai/grok-4.3` | Grok 4.3 |
|
|
61
62
|
| `xai/grok-4.20-multi-agent-beta-0309` | Grok 4.20 Multi-Agent |
|
|
@@ -126,6 +127,8 @@ the slug's internal slash and `:variant` suffix are preserved. Because its
|
|
|
126
127
|
slugs collide with other providers' prefixes, OpenRouter has no bare-model
|
|
127
128
|
inference — always address it explicitly as `openrouter/…`.
|
|
128
129
|
|
|
130
|
+
**Self-hosted vLLM / SGLang** are likewise not enumerated. Set `VLLM_BASE_URL` or `SGLANG_BASE_URL` (with an optional `*_API_KEY`) and address the served model as `vllm/<served-model>` or `sglang/<served-model>` — e.g. `sglang/Qwen/Qwen3.6-35B-A3B-FP8`. Local vs remote is just the base-URL value.
|
|
131
|
+
|
|
129
132
|
Rust applications can also define one-level Router aliases with
|
|
130
133
|
`Router::alias`. Those aliases are not part of the static registry and are not
|
|
131
134
|
configured by the stock CLI or proxy. See [Models and the Router](../concepts/routing.md).
|
|
@@ -10,6 +10,8 @@ different native API and translates only the fields that API understands.
|
|
|
10
10
|
| Google Gemini | `generateContent` / `streamGenerateContent` | `gemini*` | `x-gemini` |
|
|
11
11
|
| xAI | Responses API | `grok*` | none |
|
|
12
12
|
| OpenRouter | Chat Completions (aggregator) | none — address as `openrouter/<vendor>/<model>` | `x-openrouter` |
|
|
13
|
+
| vLLM | Chat Completions (self-hosted, `VLLM_BASE_URL`) | none — address as `vllm/<served-model>` | `x-vllm` |
|
|
14
|
+
| SGLang | Chat Completions (self-hosted, `SGLANG_BASE_URL`) | none — address as `sglang/<served-model>` | `x-sglang` |
|
|
13
15
|
|
|
14
16
|
An explicit address such as `anthropic/claude-sonnet-5` avoids inference.
|
|
15
17
|
The named provider must be registered in the Router—that normally means its
|
|
@@ -24,6 +26,7 @@ API key is configured.
|
|
|
24
26
|
| 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 |
|
|
25
27
|
| 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
28
|
| 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) |
|
|
29
|
+
| vLLM / SGLang | Passthrough to a self-hosted server — configured by base URL (local or remote), auth optional | `reasoning`/`reasoning_content` normalized to `reasoning_content`; `reasoning_effort` forwarded (honored per-model); server-specific params (`chat_template_kwargs`, `guided_json`, `top_k`, `separate_reasoning`, …) go under `x-vllm`/`x-sglang`. Reasoning/tool parsing depend on the server's launch flags (`--reasoning-parser`, `--tool-call-parser`) |
|
|
27
30
|
|
|
28
31
|
OpenAI and xAI do not receive `temperature`, `top_p`, `top_k`, or `stop` from
|
|
29
32
|
the portable top-level request. Anthropic and Gemini do. This is the
|
|
@@ -30,6 +30,7 @@ renamed or reshaped for the selected provider; unsupported controls are omitted.
|
|
|
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
32
|
| `x-openrouter` | object | Native passthrough/control | OpenRouter body fields (`provider`, `models`, `transforms`, `route`, native `reasoning`); `http_referer`/`x_title` become request headers |
|
|
33
|
+
| `x-vllm` / `x-sglang` | object | Native passthrough | Server-specific body params for a self-hosted vLLM/SGLang server (`chat_template_kwargs`, `separate_reasoning`, `guided_json`, `top_k`, `min_p`, …) |
|
|
33
34
|
|
|
34
35
|
There is no `x-xai` namespace. OpenAI additionally recognizes `store`,
|
|
35
36
|
`prompt_cache_key`, `prompt_cache_retention`, `safety_identifier`, and `speed`.
|
|
@@ -25,6 +25,7 @@ const MODELS: &[(&str, &str)] = &[
|
|
|
25
25
|
("gemini/gemini-3.5-flash", "Gemini 3.5 Flash"),
|
|
26
26
|
("gemini/gemini-3.1-pro-preview", "Gemini 3.1 Pro"),
|
|
27
27
|
("gemini/gemini-3-flash-preview", "Gemini 3 Flash"),
|
|
28
|
+
("xai/grok-4.6", "Grok 4.6"),
|
|
28
29
|
("xai/grok-4.5", "Grok 4.5"),
|
|
29
30
|
("xai/grok-4.3", "Grok 4.3"),
|
|
30
31
|
(
|
|
@@ -352,6 +352,15 @@ pub const MODELS: &[ModelInfo] = &[
|
|
|
352
352
|
max_output_tokens: Some(65_536),
|
|
353
353
|
capabilities: CAPS_STD,
|
|
354
354
|
},
|
|
355
|
+
ModelInfo {
|
|
356
|
+
id: "xai/grok-4.6",
|
|
357
|
+
provider: "xai",
|
|
358
|
+
name: "grok-4.6",
|
|
359
|
+
label: "Grok 4.6",
|
|
360
|
+
context_window_tokens: Some(500_000),
|
|
361
|
+
max_output_tokens: None,
|
|
362
|
+
capabilities: CAPS_XAI,
|
|
363
|
+
},
|
|
355
364
|
ModelInfo {
|
|
356
365
|
id: "xai/grok-4.5",
|
|
357
366
|
provider: "xai",
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
use crate::error::{Result, ShimError};
|
|
2
|
+
use crate::provider::{Provider, ProviderRequest};
|
|
3
|
+
use crate::vision;
|
|
4
|
+
use serde_json::{json, Value};
|
|
5
|
+
|
|
6
|
+
/// A generic OpenAI Chat Completions-compatible provider for **self-hosted**
|
|
7
|
+
/// inference servers — vLLM and SGLang. Like OpenRouter it's a passthrough
|
|
8
|
+
/// (messages, tools, `image_url` vision, and `response_format` are already in
|
|
9
|
+
/// the target shape), but two things differ from a hosted aggregator:
|
|
10
|
+
///
|
|
11
|
+
/// - **The base URL is configuration**, not a constant — that's what "local vs
|
|
12
|
+
/// remote" means (`http://localhost:8000/v1` vs `https://host/v1`).
|
|
13
|
+
/// - **Auth is optional** — these servers accept unauthenticated requests unless
|
|
14
|
+
/// launched with `--api-key`, so the `Authorization` header is sent only when
|
|
15
|
+
/// a key is configured.
|
|
16
|
+
///
|
|
17
|
+
/// `name` (e.g. `"vllm"` / `"sglang"`) is both the provider key and the
|
|
18
|
+
/// extension namespace: server-specific params (`chat_template_kwargs`,
|
|
19
|
+
/// `separate_reasoning`, `guided_json`, `top_k`, …) go under `x-<name>`.
|
|
20
|
+
pub struct OpenAiCompatible {
|
|
21
|
+
pub name: String,
|
|
22
|
+
pub base_url: String,
|
|
23
|
+
pub api_key: Option<String>,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
impl OpenAiCompatible {
|
|
27
|
+
pub fn new(
|
|
28
|
+
name: impl Into<String>,
|
|
29
|
+
base_url: impl Into<String>,
|
|
30
|
+
api_key: Option<String>,
|
|
31
|
+
) -> Self {
|
|
32
|
+
Self {
|
|
33
|
+
name: name.into(),
|
|
34
|
+
base_url: base_url.into(),
|
|
35
|
+
api_key,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Strip llmshim-normalized / foreign-provider fields and normalize content
|
|
41
|
+
/// blocks to Chat Completions form. Messages, `tool_calls`, and `role: "tool"`
|
|
42
|
+
/// stay in Chat Completions shape (the target format).
|
|
43
|
+
fn sanitize_messages(messages: &[Value]) -> Vec<Value> {
|
|
44
|
+
messages
|
|
45
|
+
.iter()
|
|
46
|
+
.map(|msg| {
|
|
47
|
+
let mut out = msg.clone();
|
|
48
|
+
if let Some(obj) = out.as_object_mut() {
|
|
49
|
+
obj.remove("reasoning_content"); // regenerated server-side; don't echo back
|
|
50
|
+
obj.remove("reasoning_signature"); // opaque Anthropic token — never forward
|
|
51
|
+
obj.remove("redacted_reasoning_content"); // opaque Anthropic token — never forward
|
|
52
|
+
obj.remove("annotations");
|
|
53
|
+
obj.remove("refusal");
|
|
54
|
+
}
|
|
55
|
+
if let Some(content) = out.get("content").cloned() {
|
|
56
|
+
if content.is_array() {
|
|
57
|
+
let translated =
|
|
58
|
+
vision::translate_content_blocks(&content, vision::to_openai_chat);
|
|
59
|
+
out["content"] = vision::text_blocks_to_chat(&translated);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
out
|
|
63
|
+
})
|
|
64
|
+
.collect()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Copy OpenRouter/vLLM/SGLang's `reasoning` field into llmshim's
|
|
68
|
+
/// `reasoning_content` convention if the latter isn't already present. (vLLM is
|
|
69
|
+
/// migrating `reasoning_content` → `reasoning`; SGLang uses `reasoning_content`.)
|
|
70
|
+
fn normalize_reasoning(obj: &mut serde_json::Map<String, Value>) {
|
|
71
|
+
if obj.contains_key("reasoning_content") {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if let Some(r) = obj
|
|
75
|
+
.get("reasoning")
|
|
76
|
+
.and_then(|r| r.as_str())
|
|
77
|
+
.filter(|s| !s.is_empty())
|
|
78
|
+
.map(str::to_string)
|
|
79
|
+
{
|
|
80
|
+
obj.insert("reasoning_content".to_string(), json!(r));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
impl Provider for OpenAiCompatible {
|
|
85
|
+
fn name(&self) -> &str {
|
|
86
|
+
&self.name
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fn transform_request(&self, model: &str, request: &Value) -> Result<ProviderRequest> {
|
|
90
|
+
let obj = request.as_object().ok_or(ShimError::MissingModel)?;
|
|
91
|
+
let messages = obj
|
|
92
|
+
.get("messages")
|
|
93
|
+
.and_then(|m| m.as_array())
|
|
94
|
+
.ok_or(ShimError::MissingModel)?;
|
|
95
|
+
|
|
96
|
+
let mut body = json!({
|
|
97
|
+
"model": model,
|
|
98
|
+
"messages": sanitize_messages(messages),
|
|
99
|
+
});
|
|
100
|
+
let body_obj = body.as_object_mut().unwrap();
|
|
101
|
+
|
|
102
|
+
// Standard Chat Completions params (plus reasoning_effort, which vLLM and
|
|
103
|
+
// some SGLang models honor natively) — forwarded unchanged.
|
|
104
|
+
for key in [
|
|
105
|
+
"max_tokens",
|
|
106
|
+
"max_completion_tokens",
|
|
107
|
+
"temperature",
|
|
108
|
+
"top_p",
|
|
109
|
+
"frequency_penalty",
|
|
110
|
+
"presence_penalty",
|
|
111
|
+
"stop",
|
|
112
|
+
"seed",
|
|
113
|
+
"stream",
|
|
114
|
+
"stream_options",
|
|
115
|
+
"tools",
|
|
116
|
+
"tool_choice",
|
|
117
|
+
"parallel_tool_calls",
|
|
118
|
+
"response_format",
|
|
119
|
+
"logprobs",
|
|
120
|
+
"top_logprobs",
|
|
121
|
+
"n",
|
|
122
|
+
"reasoning_effort",
|
|
123
|
+
] {
|
|
124
|
+
if let Some(v) = obj.get(key) {
|
|
125
|
+
body_obj.insert(key.to_string(), v.clone());
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// x-<name> namespace: server-specific params (sampling knobs like top_k /
|
|
130
|
+
// min_p, guided_json / regex / ebnf, chat_template_kwargs,
|
|
131
|
+
// separate_reasoning, …) are copied straight into the body.
|
|
132
|
+
let ns = format!("x-{}", self.name);
|
|
133
|
+
if let Some(ext) = obj.get(&ns).and_then(|e| e.as_object()) {
|
|
134
|
+
for (k, v) in ext {
|
|
135
|
+
body_obj.insert(k.clone(), v.clone());
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())];
|
|
140
|
+
// Auth is optional — self-hosted servers are unauthenticated unless
|
|
141
|
+
// launched with --api-key.
|
|
142
|
+
if let Some(key) = &self.api_key {
|
|
143
|
+
if !key.is_empty() {
|
|
144
|
+
headers.push(("Authorization".to_string(), format!("Bearer {key}")));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
|
|
149
|
+
Ok(ProviderRequest { url, headers, body })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
fn transform_response(&self, _model: &str, mut response: Value) -> Result<Value> {
|
|
153
|
+
if let Some(err) = response.get("error") {
|
|
154
|
+
if !err.is_null() {
|
|
155
|
+
let message = err
|
|
156
|
+
.get("message")
|
|
157
|
+
.and_then(|m| m.as_str())
|
|
158
|
+
.unwrap_or("unknown error")
|
|
159
|
+
.to_string();
|
|
160
|
+
let status = err.get("code").and_then(|c| c.as_u64()).unwrap_or(400) as u16;
|
|
161
|
+
return Err(ShimError::ProviderError {
|
|
162
|
+
status,
|
|
163
|
+
body: message,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Already Chat Completions-shaped. Normalize the reasoning field name.
|
|
169
|
+
if let Some(choices) = response.get_mut("choices").and_then(|c| c.as_array_mut()) {
|
|
170
|
+
for choice in choices {
|
|
171
|
+
if let Some(msg) = choice.get_mut("message").and_then(|m| m.as_object_mut()) {
|
|
172
|
+
normalize_reasoning(msg);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
Ok(response)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fn transform_stream_chunk(&self, _model: &str, chunk: &str) -> Result<Option<String>> {
|
|
181
|
+
let mut parsed: Value = match serde_json::from_str(chunk) {
|
|
182
|
+
Ok(v) => v,
|
|
183
|
+
Err(_) => return Ok(None),
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if let Some(choices) = parsed.get_mut("choices").and_then(|c| c.as_array_mut()) {
|
|
187
|
+
for choice in choices {
|
|
188
|
+
if let Some(delta) = choice.get_mut("delta").and_then(|d| d.as_object_mut()) {
|
|
189
|
+
normalize_reasoning(delta);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
Ok(Some(serde_json::to_string(&parsed)?))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -189,11 +189,14 @@ fn is_reasoning_name_locked(model: &str) -> bool {
|
|
|
189
189
|
model.to_lowercase().contains("4.20")
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
/// grok-4.5 cannot disable reasoning: `effort: "none"` -> 400 ("does
|
|
193
|
-
/// support `reasoning_effort` value `none`", verified live). Unified effort
|
|
194
|
-
/// "none" clamps to "low". grok-4.3
|
|
192
|
+
/// grok-4.5 / grok-4.6 cannot disable reasoning: `effort: "none"` -> 400 ("does
|
|
193
|
+
/// not support `reasoning_effort` value `none`", verified live). Unified effort
|
|
194
|
+
/// "none" clamps to "low". grok-4.3 DOES accept "none".
|
|
195
195
|
fn reasoning_cannot_disable(model: &str) -> bool {
|
|
196
|
-
model.to_lowercase()
|
|
196
|
+
let m = model.to_lowercase();
|
|
197
|
+
// grok-4.5 and grok-4.6 both 400 on `reasoning_effort: "none"` (verified
|
|
198
|
+
// live); unified "none" clamps to "low" for them. grok-4.3 accepts "none".
|
|
199
|
+
m.contains("4.5") || m.contains("4.6")
|
|
197
200
|
}
|
|
198
201
|
|
|
199
202
|
impl Provider for Xai {
|
|
@@ -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::openai_compat::OpenAiCompatible;
|
|
6
7
|
use crate::providers::openrouter::OpenRouter;
|
|
7
8
|
use crate::providers::xai::Xai;
|
|
8
9
|
use std::collections::HashMap;
|
|
@@ -97,6 +98,22 @@ impl Router {
|
|
|
97
98
|
if let Ok(key) = std::env::var("OPENROUTER_API_KEY") {
|
|
98
99
|
router = router.register("openrouter", Box::new(OpenRouter::new(key)));
|
|
99
100
|
}
|
|
101
|
+
// Self-hosted OpenAI-compatible servers: the base URL is the config
|
|
102
|
+
// (local vs remote); the API key is optional. Registered only when the
|
|
103
|
+
// base URL is set. Address as `vllm/<served-model>` / `sglang/<served-model>`.
|
|
104
|
+
if let Ok(base) = std::env::var("VLLM_BASE_URL") {
|
|
105
|
+
let key = std::env::var("VLLM_API_KEY").ok().filter(|k| !k.is_empty());
|
|
106
|
+
router = router.register("vllm", Box::new(OpenAiCompatible::new("vllm", base, key)));
|
|
107
|
+
}
|
|
108
|
+
if let Ok(base) = std::env::var("SGLANG_BASE_URL") {
|
|
109
|
+
let key = std::env::var("SGLANG_API_KEY")
|
|
110
|
+
.ok()
|
|
111
|
+
.filter(|k| !k.is_empty());
|
|
112
|
+
router = router.register(
|
|
113
|
+
"sglang",
|
|
114
|
+
Box::new(OpenAiCompatible::new("sglang", base, key)),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
100
117
|
|
|
101
118
|
router
|
|
102
119
|
}
|
|
@@ -257,3 +257,27 @@ pub fn text_blocks_to_openai(content: &Value) -> Value {
|
|
|
257
257
|
_ => content.clone(),
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
|
+
|
|
261
|
+
/// Translate text blocks to OpenAI Chat Completions format ("text" instead of
|
|
262
|
+
/// the Responses API's "input_text") — for OpenAI-compatible targets like
|
|
263
|
+
/// OpenRouter, vLLM, and SGLang.
|
|
264
|
+
pub fn text_blocks_to_chat(content: &Value) -> Value {
|
|
265
|
+
match content {
|
|
266
|
+
Value::Array(blocks) => {
|
|
267
|
+
let translated: Vec<Value> = blocks
|
|
268
|
+
.iter()
|
|
269
|
+
.map(|block| {
|
|
270
|
+
if block.get("type").and_then(|t| t.as_str()) == Some("input_text") {
|
|
271
|
+
let mut out = block.clone();
|
|
272
|
+
out["type"] = Value::String("text".into());
|
|
273
|
+
out
|
|
274
|
+
} else {
|
|
275
|
+
block.clone()
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
.collect();
|
|
279
|
+
Value::Array(translated)
|
|
280
|
+
}
|
|
281
|
+
_ => content.clone(),
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/// Integration tests for a self-hosted SGLang (or any OpenAI-compatible) server.
|
|
2
|
+
/// Point SGLANG_BASE_URL at a running server and run:
|
|
3
|
+
/// SGLANG_BASE_URL=http://host:30000/v1 SGLANG_TEST_MODEL=<served-name> \
|
|
4
|
+
/// cargo test --test integration_sglang -- --ignored --nocapture
|
|
5
|
+
/// SGLANG_API_KEY is optional (only if the server was launched with --api-key).
|
|
6
|
+
use serde_json::json;
|
|
7
|
+
|
|
8
|
+
fn router() -> llmshim::router::Router {
|
|
9
|
+
llmshim::router::Router::from_env()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
fn model() -> Option<String> {
|
|
13
|
+
let m = std::env::var("SGLANG_TEST_MODEL").ok()?;
|
|
14
|
+
Some(format!("sglang/{m}"))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#[tokio::test]
|
|
18
|
+
#[ignore]
|
|
19
|
+
async fn sglang_basic_completion() {
|
|
20
|
+
if std::env::var("SGLANG_BASE_URL").is_err() {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
let Some(model) = model() else {
|
|
24
|
+
eprintln!("set SGLANG_TEST_MODEL");
|
|
25
|
+
return;
|
|
26
|
+
};
|
|
27
|
+
let router = router();
|
|
28
|
+
let req = json!({
|
|
29
|
+
"model": model,
|
|
30
|
+
"messages": [{"role": "user", "content": "In one short sentence, what is Rust?"}],
|
|
31
|
+
"max_tokens": 512,
|
|
32
|
+
});
|
|
33
|
+
let resp = llmshim::completion(&router, &req).await.unwrap();
|
|
34
|
+
assert_eq!(resp["object"], "chat.completion");
|
|
35
|
+
let content = resp["choices"][0]["message"]["content"]
|
|
36
|
+
.as_str()
|
|
37
|
+
.unwrap_or("");
|
|
38
|
+
assert!(!content.is_empty(), "expected a response, got: {resp}");
|
|
39
|
+
println!("model={} | said: {content}", resp["model"]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#[tokio::test]
|
|
43
|
+
#[ignore]
|
|
44
|
+
async fn sglang_reasoning() {
|
|
45
|
+
if std::env::var("SGLANG_BASE_URL").is_err() {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let Some(model) = model() else { return };
|
|
49
|
+
let router = router();
|
|
50
|
+
let req = json!({
|
|
51
|
+
"model": model,
|
|
52
|
+
"messages": [{"role": "user", "content": "What is 17 * 24? Reason step by step, then give the answer."}],
|
|
53
|
+
"max_tokens": 2000,
|
|
54
|
+
"reasoning_effort": "high",
|
|
55
|
+
});
|
|
56
|
+
let resp = llmshim::completion(&router, &req).await.unwrap();
|
|
57
|
+
let msg = &resp["choices"][0]["message"];
|
|
58
|
+
let reasoning = msg["reasoning_content"].as_str().unwrap_or("");
|
|
59
|
+
let content = msg["content"].as_str().unwrap_or("");
|
|
60
|
+
assert!(
|
|
61
|
+
!reasoning.is_empty() || !content.is_empty(),
|
|
62
|
+
"expected reasoning or answer, got: {resp}"
|
|
63
|
+
);
|
|
64
|
+
println!(
|
|
65
|
+
"reasoning chars={}, answer chars={}",
|
|
66
|
+
reasoning.len(),
|
|
67
|
+
content.len()
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#[tokio::test]
|
|
72
|
+
#[ignore]
|
|
73
|
+
async fn sglang_tool_call() {
|
|
74
|
+
if std::env::var("SGLANG_BASE_URL").is_err() {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
let Some(model) = model() else { return };
|
|
78
|
+
let router = router();
|
|
79
|
+
let req = json!({
|
|
80
|
+
"model": model,
|
|
81
|
+
"messages": [{"role": "user", "content": "What's the weather in Tokyo? Use the get_weather tool."}],
|
|
82
|
+
"max_tokens": 512,
|
|
83
|
+
"tools": [{
|
|
84
|
+
"type": "function",
|
|
85
|
+
"function": {
|
|
86
|
+
"name": "get_weather",
|
|
87
|
+
"description": "Get the weather for a city",
|
|
88
|
+
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
|
|
89
|
+
}
|
|
90
|
+
}]
|
|
91
|
+
});
|
|
92
|
+
let resp = llmshim::completion(&router, &req).await.unwrap();
|
|
93
|
+
let tool_calls = resp["choices"][0]["message"].get("tool_calls");
|
|
94
|
+
assert!(tool_calls.is_some(), "expected a tool call, got: {resp}");
|
|
95
|
+
println!(
|
|
96
|
+
"tool call ok: {}",
|
|
97
|
+
tool_calls.unwrap()[0]["function"]["name"]
|
|
98
|
+
);
|
|
99
|
+
}
|