From 46909b5fbced7e015c93ccd94f5ffa490ca0810a Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Fri, 21 Aug 2026 02:33:48 +0200 Subject: [PATCH 1/5] chore: fix flaky watcher test, patch npm advisories, add Fedora RPM builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four pieces of maintenance and one new deliverable. Flaky test — TestWatchConfig_DetectsFileChange failed roughly 1 in 4 full-suite race runs and blocked pushes via the pre-push hook. Two races were involved: WatchConfig registers its fsnotify watch asynchronously, so the test's sleep-then-write could lose the write entirely; and AtomicConfig.Reload runs reload callbacks before publishing the new pointer, so asserting via Get() could read the old config. Adds WatchConfigWithReady, which signals once the watch is registered, letting the test wait for readiness, write once, and assert on the config handed to the callback. WatchConfig keeps its signature and delegates, so both production call sites are unchanged. OnReload now documents the callback-before-publish ordering. Dependency advisories — postcss 8.5.18 to 8.5.26 (GHSA-fxqj-rqcc-2cmp, Dependabot alert #2) and nanoid to 3.3.18 (GHSA-2v37-7h3g-55p8, high severity, never alerted on). Both were in range of the existing tailwindcss constraint, so no breaking upgrade. npm audit is clean and the checked-in compiled-tailwind.css is byte-identical after a rebuild. Adds .github/dependabot.yml covering gomod, npm and github-actions, since the unreported nanoid advisory showed the gap. Fedora packaging — builds x86_64 and aarch64 RPMs with nfpm (pinned v2.47.0) and attaches them to both release channels. The RPMs are produced inside the existing release job and passed to the single gh release create call: this repo publishes immutable releases, so assets added afterwards would be rejected. A verification step reads the ELF header directly (magic, class, e_machine) rather than grepping file output, which words things differently on macOS and would silently always pass. Package layout follows Fedora conventions: binary in /usr/bin, config in /etc marked noreplace so upgrades keep local edits, license in /usr/share/licenses, and an opt-in systemd *user* unit in /usr/lib/systemd/user — a system unit would wrongly imply a machine-level daemon for a loopback developer proxy. Adds a make rpm target for local inspection, since rpm -qip is unavailable on the macOS runner. Docs — corrections found by auditing every doc against the source: the long-context threshold is 100K not 80K, the vision tier was missing from the documented scenario priority, there are four endpoint types not two, the Anthropic endpoint covers Qwen as well as MiniMax, model tables now match modelMetadata, the scenario defaults now match the shipped default_config.json, OpenRouter's API-key env vars were undocumented, and config.example.json gained the six real config blocks it was missing. Removes a documented request_dedup option that does not exist in the code and documents the three analytics routes that do. Fixes stale beta-version formats and broken links, and flags the zh translations that lag. --- .github/dependabot.yml | 28 +++++ .github/workflows/beta-release.yml | 84 +++++++++++++- .github/workflows/release.yml | 84 +++++++++++++- .gitignore | 2 + CLAUDE.md | 95 +++++++++------ CONFIGURATION.md | 29 ++--- CONTRIBUTING.md | 16 ++- Makefile | 28 ++++- README.md | 4 + RELEASE_PROCESS.md | 37 +++--- configs/config.example.json | 29 +++++ docs/fedora-setup.md | 141 +++++++++++++++++++++-- docs/howto-custom-routing.md | 46 ++++++-- docs/models.md | 47 ++++---- docs/reference-api.md | 25 +++- docs/zh/CONFIGURATION.md | 2 + docs/zh/INSTALLATION.md | 2 + docs/zh/MODELS.md | 6 +- docs/zh/TROUBLESHOOTING.md | 2 + internal/config/atomic.go | 5 + internal/config/watcher.go | 24 ++++ internal/config/watcher_test.go | 58 +++++++--- package-lock.json | 15 +-- packaging/nfpm.yaml | 86 ++++++++++++++ packaging/systemd/routatic-proxy.service | 27 +++++ 25 files changed, 790 insertions(+), 132 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 packaging/nfpm.yaml create mode 100644 packaging/systemd/routatic-proxy.service diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..41eab7ac --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 +updates: + # Go modules (go.mod / go.sum at the repo root) + - package-ecosystem: gomod + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: "chore(deps)" + + # npm (package.json at the repo root — Tailwind CSS build tooling) + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: "chore(deps)" + + # GitHub Actions used by the release pipelines (pinned by SHA) + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: "ci(deps)" diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 92df5fed..9370847a 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -131,6 +131,84 @@ jobs: echo "Built binaries:" ls -lh dist/ + # RPM packaging runs here rather than on a Linux runner: nfpm is pure Go + # and writes RPMs natively (no rpmbuild), and every asset must go into the + # single atomic `gh release create` call below — this repo publishes + # immutable releases, which reject post-creation asset uploads. + - name: Verify Linux binaries + run: | + set -euo pipefail + + # Read the ELF header directly instead of grepping `file` output: + # macOS and Linux word it differently, and a guard that silently + # always passes is worse than no guard. + # bytes 0-3 magic 7f 45 4c 46 + # byte 4 class 02 = 64-bit + # byte 5 data 01 = little-endian + # bytes 18-19 e_machine (LE): 3e00 = x86-64, b700 = AArch64 + check() { + BIN="dist/routatic-proxy_linux-$1" + EXPECT_MACHINE="$2" + + if [ ! -f "$BIN" ]; then + echo "::error::Missing $BIN — the cross-compile step did not produce it" + exit 1 + fi + if [ ! -x "$BIN" ]; then + echo "::error::$BIN is not executable" + exit 1 + fi + + HEADER=$(dd if="$BIN" bs=1 count=20 2>/dev/null | od -An -tx1 | tr -d ' \n') + MAGIC="${HEADER:0:8}" + CLASS="${HEADER:8:2}" + MACHINE="${HEADER:36:4}" + + if [ "$MAGIC" != "7f454c46" ]; then + echo "::error::$BIN is not an ELF binary (magic=$MAGIC)" + exit 1 + fi + if [ "$CLASS" != "02" ]; then + echo "::error::$BIN is not 64-bit ELF (class=$CLASS)" + exit 1 + fi + if [ "$MACHINE" != "$EXPECT_MACHINE" ]; then + echo "::error::$BIN has wrong ELF machine: got $MACHINE, want $EXPECT_MACHINE" + exit 1 + fi + + echo "$1: ok (ELF64, e_machine=$MACHINE)" + } + + check amd64 3e00 + check arm64 b700 + + - name: Build RPM packages + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.47.0 + + # nfpm's semver schema turns 0.5.3-beta.1 into RPM version + # 0.5.3~beta.1, which sorts below the matching stable release. + # Go arch names go in; nfpm maps amd64 -> x86_64, arm64 -> aarch64. + for ARCH in amd64 arm64; do + echo "Packaging ${ARCH}..." + NFPM_VERSION="$VERSION" \ + NFPM_ARCH="$ARCH" \ + NFPM_BINARY="dist/routatic-proxy_linux-${ARCH}" \ + "$(go env GOPATH)/bin/nfpm" package \ + --config packaging/nfpm.yaml --packager rpm --target dist/ + done + + COUNT=$(find dist -name '*.rpm' | wc -l | tr -d ' ') + if [ "$COUNT" -ne 2 ]; then + echo "::error::Expected 2 RPMs, found ${COUNT}" + exit 1 + fi + ls -lh dist/*.rpm + - name: Stage Windows binaries for signing if: ${{ steps.signing.outputs.enabled == 'true' }} run: | @@ -170,7 +248,10 @@ jobs: - name: Generate checksums run: | cd dist - sha256sum routatic-proxy_* > checksums.txt + # Covers the raw binaries (routatic-proxy_*) and the RPMs + # (routatic-proxy--..rpm) in one pass. Neither + # glob can match checksums.txt itself. + sha256sum routatic-proxy_* routatic-proxy-*.rpm > checksums.txt cat checksums.txt - name: Build CGO-enabled binary (for DMG) @@ -287,6 +368,7 @@ jobs: --notes-file changelog.md \ --prerelease \ dist/routatic-proxy_* \ + dist/routatic-proxy-*.rpm \ dist/checksums.txt \ bin/RoutaticProxy.dmg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bfefecb..b250a1f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,6 +129,84 @@ jobs: echo "Built binaries:" ls -lh dist/ + # RPM packaging runs here rather than on a Linux runner: nfpm is pure Go + # and writes RPMs natively (no rpmbuild), and every asset must go into the + # single atomic `gh release create` call below — this repo publishes + # immutable releases, which reject post-creation asset uploads. + - name: Verify Linux binaries + run: | + set -euo pipefail + + # Read the ELF header directly instead of grepping `file` output: + # macOS and Linux word it differently, and a guard that silently + # always passes is worse than no guard. + # bytes 0-3 magic 7f 45 4c 46 + # byte 4 class 02 = 64-bit + # byte 5 data 01 = little-endian + # bytes 18-19 e_machine (LE): 3e00 = x86-64, b700 = AArch64 + check() { + BIN="dist/routatic-proxy_linux-$1" + EXPECT_MACHINE="$2" + + if [ ! -f "$BIN" ]; then + echo "::error::Missing $BIN — the cross-compile step did not produce it" + exit 1 + fi + if [ ! -x "$BIN" ]; then + echo "::error::$BIN is not executable" + exit 1 + fi + + HEADER=$(dd if="$BIN" bs=1 count=20 2>/dev/null | od -An -tx1 | tr -d ' \n') + MAGIC="${HEADER:0:8}" + CLASS="${HEADER:8:2}" + MACHINE="${HEADER:36:4}" + + if [ "$MAGIC" != "7f454c46" ]; then + echo "::error::$BIN is not an ELF binary (magic=$MAGIC)" + exit 1 + fi + if [ "$CLASS" != "02" ]; then + echo "::error::$BIN is not 64-bit ELF (class=$CLASS)" + exit 1 + fi + if [ "$MACHINE" != "$EXPECT_MACHINE" ]; then + echo "::error::$BIN has wrong ELF machine: got $MACHINE, want $EXPECT_MACHINE" + exit 1 + fi + + echo "$1: ok (ELF64, e_machine=$MACHINE)" + } + + check amd64 3e00 + check arm64 b700 + + - name: Build RPM packages + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.47.0 + + # nfpm's semver schema turns 0.5.3-beta.1 into RPM version + # 0.5.3~beta.1, which sorts below the matching stable release. + # Go arch names go in; nfpm maps amd64 -> x86_64, arm64 -> aarch64. + for ARCH in amd64 arm64; do + echo "Packaging ${ARCH}..." + NFPM_VERSION="$VERSION" \ + NFPM_ARCH="$ARCH" \ + NFPM_BINARY="dist/routatic-proxy_linux-${ARCH}" \ + "$(go env GOPATH)/bin/nfpm" package \ + --config packaging/nfpm.yaml --packager rpm --target dist/ + done + + COUNT=$(find dist -name '*.rpm' | wc -l | tr -d ' ') + if [ "$COUNT" -ne 2 ]; then + echo "::error::Expected 2 RPMs, found ${COUNT}" + exit 1 + fi + ls -lh dist/*.rpm + - name: Stage Windows binaries for signing if: ${{ steps.signing.outputs.enabled == 'true' }} run: | @@ -168,7 +246,10 @@ jobs: - name: Generate checksums run: | cd dist - sha256sum routatic-proxy_* > checksums.txt + # Covers the raw binaries (routatic-proxy_*) and the RPMs + # (routatic-proxy--..rpm) in one pass. Neither + # glob can match checksums.txt itself. + sha256sum routatic-proxy_* routatic-proxy-*.rpm > checksums.txt cat checksums.txt - name: Build CGO-enabled binary (for DMG) @@ -289,6 +370,7 @@ jobs: --title "Release ${{ steps.version.outputs.tag }}" \ --notes-file changelog.md \ dist/routatic-proxy_* \ + dist/routatic-proxy-*.rpm \ dist/checksums.txt \ bin/RoutaticProxy.dmg diff --git a/.gitignore b/.gitignore index 1fb1d342..f21ca2e6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ oc-go-cc /routatic-hosted .trunk dist/ +dist-rpm/ +*.rpm brag-output-** .DS_Store .kimchi diff --git a/CLAUDE.md b/CLAUDE.md index bf13b66d..c0aa1896 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co make build # Build binary to bin/routatic-proxy (CGO disabled by default) make run # Run without building make test # Run tests with race detector -make lint # go vet + test +make lint # gofmt check + go vet (does NOT run tests) make clean # Remove build artifacts make install # Build and install to $GOPATH/bin make dist # Cross-compile for all platforms @@ -38,37 +38,60 @@ make dist # Cross-compile for all platforms If a model's upstream doesn't support Anthropic tool format (`type: "custom"` server-tool shorthands), set `"anthropic_tools_disabled": true` in the model config to force it through the Chat Completions transform path instead of the raw Anthropic endpoint. -**Two API endpoints:** +**Four endpoint types** (`EndpointType`, `internal/models/classifier.go`): + +- `EndpointChatCompletions` — OpenAI-compatible `/v1/chat/completions`. The default, and what most models use. +- `EndpointAnthropic` — Anthropic `/v1/messages`. +- `EndpointResponses` — OpenAI native `/v1/responses`. Used by the GPT-5.x families (`IsResponsesModel`). +- `EndpointGemini` — Google `/v1/models/{id}`. Used by `gemini-3.5-flash`, `gemini-3.1-pro`, `gemini-3-flash` (`IsGeminiModel`). + +Which models take the Anthropic endpoint depends on the provider: + +- **Go provider** — `IsAnthropicModel` (classifier.go) returns true for `minimax-m2.5`, `minimax-m2.7`, `minimax-m3` **and** `qwen3.5-plus`, `qwen3.6-plus`, `qwen3.7-plus`, `qwen3.7-max`. Everything else goes through the Chat Completions transform. +- **Zen provider** — `ClassifyEndpoint` is Zen-specific. `IsZenAnthropicModel` routes any `claude-*` or `qwen*` model to Anthropic; MiniMax on Zen uses Chat Completions (unlike MiniMax on the Go provider). + +**Available models:** the built-in capability registry is `modelMetadata` in `internal/config/model_registry.go`. It supplies context window, max output tokens, and vision support whenever the runtime config omits them (`ResolveModelConfig`). Every entry has `SupportsTools: true`. + +| Model ID | Typical provider | Context | Max output | Vision | Best For | +|----------|------------------|---------|-----------|--------|----------| +| `deepseek-v4-pro` | Go | 1M | 8192 | no | Default + complex scenarios in the shipped config | +| `deepseek-v4-flash` | Go | 1M | 4096 | no | Background / fast scenarios | +| `deepseek-v4-flash-free` | Zen | 1M | 4096 | no | Free-tier fallback | +| `glm-5.2` | Go | 200K | 8192 | no | Think scenario, architecture decisions | +| `glm-5.1` | Go | 200K | 8192 | no | Complex patterns, tool operations | +| `glm-5` | Go | 200K | 8192 | no | Reasoning tasks (deprecated May 14, 2026) | +| `kimi-k3` | Go | 1M | 131072 | yes | Flagship Kimi, huge output budget, multimodal | +| `kimi-k2.7-code` | Go | 256K | 32768 | yes | Large code generation | +| `kimi-k2.6` | Go | 256K | 8192 | yes | General purpose, common fallback | +| `kimi-k2.5` | Go | 256K | 8192 | yes | Previous-generation Kimi fallback | +| `minimax-m3` | Go | 1M | 128000 | no | Long-context scenario in the shipped config | +| `minimax-m2.7` | Go | 200K | 8192 | no | Previous MiniMax generation | +| `minimax-m2.5` | Go | 200K | 4096 | no | Older MiniMax generation | +| `mimo-v2.5-pro` | Go | 1M | 16384 | no | Step-by-step reasoning, larger output | +| `mimo-v2.5` | Go | 1M | 8192 | no | Step-by-step reasoning | +| `mimo-v2.5-free` | Zen | 1M | 8192 | no | Free-tier fallback | +| `mimo-v2-omni` | Go | 1M | 8192 | yes | Multimodal MiMo | +| `qwen3.7-max` | Go | 1M | 8192 | yes | Complex coding, Qwen's best quality | +| `qwen3.7-plus` | Go | 1M | 8192 | yes | Streaming, low-latency | +| `qwen3.6-plus` | Go | 1M | 8192 | yes | Streaming fallback | +| `qwen3.5-plus` | Go | 1M | 8192 | yes | Simple read-only ops | + +The "typical provider" column reflects how the shipped config wires each model; the registry itself is provider-agnostic, so any model can be pointed at any provider in `config.json`. Zen additionally exposes many models that are not in the registry (Claude, Gemini, GPT-5.x, other free-tier models) — those get their capabilities from the catalog rather than `modelMetadata`. -- OpenAI endpoint (`/v1/chat/completions`) — used by most models (GLM, Kimi, MiMo, Qwen) -- Anthropic endpoint (`/v1/messages`) — used only by MiniMax models - -**Available models:** +`internal/client/opencode.go` routes Go provider models to Chat Completions; Zen models are classified by `models.ClassifyEndpoint()` in `internal/models/classifier.go`. If a model's upstream doesn't support Anthropic tool format, set `anthropic_tools_disabled: true` in config. -| Model | Provider | Type | Best For | -|-------|----------|------|----------| -| GLM-5.2 | Go | Premium | Complex reasoning, architecture decisions (new) | -| GLM-5.1 | Go | Standard | Complex patterns, tool operations | -| GLM-5 | Go | Standard | Reasoning tasks (deprecated May 14, 2026) | -| Kimi K3 | Go | Flagship | Latest Kimi, 1M context, 131K output, multimodal (new) | -| Kimi K2.7 Code | Go | Code specialist | Code generation, 32K output context | -| Kimi K2.6 | Go | Standard | General purpose, default fallback | -| Qwen3.7 Plus | Go | Fast | Streaming, low-latency (new) | -| Qwen3.7 Max | Go | Fast | Background tasks (new) | -| Qwen3.6 Plus | Go | Fast | Streaming fallback | -| Qwen3.5 Plus | Go | Fast | Simple read-only ops | -| MiniMax | Zen | Long context | 1M context window | -| MiMo | Go | Reasoning | Step-by-step reasoning | +**Scenario detection priority** (`DetectScenario`, `internal/router/scenarios.go`). Models below are the built-in defaults from `cmd/routatic-proxy/templates/default_config.json`, which is what `routatic-proxy init` writes: -`internal/client/opencode.go` routes Go provider models to Chat Completions; Zen models are classified by `models.ClassifyEndpoint()` in `internal/models/classifier.go`. If a model's upstream doesn't support Anthropic tool format, set `anthropic_tools_disabled: true` in config. +1. **Long context** — token count > threshold (`getLongContextThreshold`, default **100K**, configurable via the `long_context` model's `context_threshold`) → `minimax-m3`. If the latest user message also carries an image, the scenario is `vision_long_context` instead. +2. **Vision** — the latest user message contains an image. Splits by intent: `vision_complex` when the text also shows complex intent, otherwise `vision`. +3. **Complex** — architectural patterns or tool-heavy operations → `deepseek-v4-pro`. +4. **Think** — reasoning keywords → `glm-5.2`. +5. **Background** — simple read-only ops with no tools → `deepseek-v4-flash`. +6. **Default** → `deepseek-v4-pro`. -**Scenario detection priority** (`internal/router/scenarios.go`): +The three vision scenarios are `ScenarioVision`, `ScenarioVisionComplex`, and `ScenarioVisionLongContext` (scenarios.go). The shipped default config has no `vision*` model entries, so vision requests fall through to the ordinary scenario models unless you add them. -1. Long Context (>80K tokens, configurable) → MiniMax (1M context) -2. Complex (architectural patterns, tool operations) → GLM-5.2 -3. Think (reasoning keywords in system prompt) → GLM-5.1 -4. Background (simple read-only ops, no tools) → Qwen3.7 Max -5. Default → Kimi K2.6 +Note: the human-readable `Reason` strings inside `scenarios.go` still name older models (GLM-5.1, Qwen3.5 Plus, Kimi K2.6). They are log text only — the actual model comes from config, not from those strings. **Model overrides:** two config blocks bypass scenario routing based on the requested model. `model_overrides` matches the `model` string **exactly** (best with CC-Switch, which sends a custom model string). `model_family_overrides` maps a Claude family keyword (`opus`, `sonnet`, `haiku`) via **case-insensitive substring** match, so the versioned IDs Claude Code sends natively (`claude-opus-4-20250514`) route without CC-Switch. Precedence: exact `model_overrides` → `model_family_overrides` (longest key first) → `respect_requested_model` → scenario routing. Both are wired through `ModelRouter.RouteWithOverride` / `RouteWithFamilyOverride` (`internal/router/model_router.go`) and merged with a deduplicated scenario safety-net chain in `buildModelChain` (`internal/handlers/messages.go`). @@ -87,7 +110,7 @@ If a model's upstream doesn't support Anthropic tool format (`type: "custom"` se Resolution functions in `internal/catalog/resolve.go` extract the provider from the key prefix. `ResolvedModel.ModelID` is the model name only (without provider prefix); `ResolvedModel.CanonicalName` is the full key. -For streaming, the router downgrades to fast models (Qwen3.7 Plus) for better TTFT. +For streaming, `RouteForStreaming` downgrades complex/think requests to the `fast` scenario for better TTFT (`deepseek-v4-flash` in the shipped default config). **Deprecated models:** - GLM-5 — deprecated May 14, 2026; use GLM-5.1 or GLM-5.2 @@ -96,17 +119,19 @@ For streaming, the router downgrades to fast models (Qwen3.7 Plus) for better TT **Long-running stream policy:** The proxy never kills a stream that is actively producing bytes. The server-level `WriteTimeout` is set to 0; instead each upstream read uses a per-`Read` deadline via `http.ResponseController.SetReadDeadline` that is renewed on every successful byte. If the gap between bytes exceeds `OpenCodeGo.stream_timeout_ms` (or `OpenCodeZen.stream_timeout_ms`), the connection is treated as stuck and the request is routed to the next fallback model. Defaults to `timeout_ms` when unset. Client disconnects during a stream are logged at `Debug` level — this is normal during Claude Code tool execution and is not a failure signal. -**Provider-specific API keys:** Each provider (OpenCode Go, OpenCode Zen, AWS Bedrock) can have its own `api_key` or `api_keys` array. Provider-specific keys take precedence over global keys. This enables per-provider fallback strategies and key rotation. +**Provider-specific API keys:** Each provider (OpenCode Go, OpenCode Zen, AWS Bedrock, OpenRouter) can have its own `api_key` or `api_keys` array. Provider-specific keys take precedence over global keys. This enables per-provider fallback strategies and key rotation. Environment variable overrides (single key): - `ROUTATIC_PROXY_OPENCODE_GO_API_KEY` - `ROUTATIC_PROXY_OPENCODE_ZEN_API_KEY` - `ROUTATIC_PROXY_AWS_BEDROCK_API_KEY` +- `ROUTATIC_PROXY_OPENROUTER_API_KEY` Environment variable overrides (comma-separated keys for round-robin): - `ROUTATIC_PROXY_OPENCODE_GO_API_KEYS=key-1,key-2,key-3` - `ROUTATIC_PROXY_OPENCODE_ZEN_API_KEYS=key-1,key-2` - `ROUTATIC_PROXY_AWS_BEDROCK_API_KEYS=key-1,key-2` +- `ROUTATIC_PROXY_OPENROUTER_API_KEYS=key-1,key-2` Precedence: `*_API_KEYS` → `*_API_KEY` → global `API_KEYS` → global `API_KEY`. @@ -143,9 +168,9 @@ This project uses a dual release channel system for separating beta and producti ### Beta Channel (Automatic) - **Trigger:** Every push to `main` branch (see `.github/workflows/beta-release.yml`) -- **Version format:** `v{UPCOMING}-beta.{N}` (e.g., `v0.5.3-beta.1`), where `{N}` is a sequential counter +- **Version format:** `v{UPCOMING}-beta.{N}` (e.g., `v0.6.4-beta.1`), where `{N}` is a sequential counter - **GitHub release:** Marked as `prerelease: true` -- **Docker tags:** `v{UPCOMING}-beta.{N}`, `beta-{UPCOMING}`, and `beta` (rolling pointer to newest beta) +- **Docker tags:** `v{UPCOMING}-beta.{N}`, `beta-{PROD}` (the latest *stable* version, e.g. `beta-v0.6.3`), and `beta` (rolling pointer to newest beta) Beta releases are fully automated and include: - Test suite validation @@ -167,16 +192,16 @@ Production releases include all beta features plus: ### Version Detection Script `.github/scripts/get-versions.sh` is used by the beta workflow to: -1. Fetch tags from the `origin/releases` branch to get current production version (e.g., `v0.5.2`) -2. Increment the **patch** to the next version (e.g., `v0.5.3`) - **beta is based on the upcoming patch release** +1. Fetch tags from the `origin/releases` branch to get current production version (e.g., `v0.6.3`) +2. Increment the **patch** to the next version (e.g., `v0.6.4`) - **beta is based on the upcoming patch release** 3. Generate beta version by appending `-beta.{N}`, where `{N}` is `max(existing beta counters for this upcoming version) + 1` - **the counter resets to 1 once the upcoming version ships as stable** 4. Output both versions as JSON for CI consumption **Version Format Explanation:** -- `v0.5.3` = The upcoming production version (patch incremented from latest production) +- `v0.6.4` = The upcoming production version (patch incremented from latest production) - `beta.1` = Sequential prerelease counter for that upcoming version -- Full example: stable `v0.5.2` → `v0.5.3-beta.1`, then `v0.5.3-beta.2`, ... until `v0.5.3` ships → `v0.5.4-beta.1` +- Full example: stable `v0.6.3` → `v0.6.4-beta.1`, then `v0.6.4-beta.2`, ... until `v0.6.4` ships → `v0.6.5-beta.1` ### Creating a Production Release diff --git a/CONFIGURATION.md b/CONFIGURATION.md index e650b55b..392787b9 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -461,13 +461,14 @@ kill -HUP The proxy automatically detects the type of request and routes to the appropriate model based on context size and content analysis: -| Scenario | Trigger | Model | Why | -| ---------------- | --------------------------------------------------- | ------------ | ----------------------------------------------- | -| **Long Context** | >80K tokens (configurable) | MiniMax M2.7 | 1M context window vs 128-256K for others | -| **Complex** | "architect", "refactor", "complex" in system prompt | GLM-5.1 | Best reasoning & architectural understanding | -| **Think** | "think", "plan", "reason" in system prompt | GLM-5 | Good reasoning, cheaper than GLM-5.1 | -| **Background** | "read file", "grep", "list directory" | Qwen3.5 Plus | Cheapest (~10K req/5hr), perfect for simple ops | -| **Default** | Everything else | Kimi K2.6 | Best balance of quality & cost (~1.8K req/5hr) | +| Scenario | Trigger | Default Model | Why | +| ---------------- | --------------------------------------------------- | ------------------- | -------------------------------------------- | +| **Long Context** | >100K tokens (configurable) | `minimax-m3` | 1M context window | +| **Vision** | Latest user message contains an image | (not preconfigured) | Splits into `vision` / `vision_complex` | +| **Complex** | "architect", "refactor", "complex" in system prompt | `deepseek-v4-pro` | Best reasoning & architectural understanding | +| **Think** | "think", "plan", "reason" in system prompt | `glm-5.2` | Strong reasoning at lower cost | +| **Background** | "read file", "grep", "list directory" | `deepseek-v4-flash` | Cheap, perfect for simple ops | +| **Default** | Everything else | `deepseek-v4-pro` | Best balance of quality & cost | **See [MODELS.md](MODELS.md) for detailed model capabilities, costs, and routing recommendations.** @@ -477,12 +478,14 @@ DeepSeek V4 users can set any scenario model to `deepseek-v4-pro` or `deepseek-v | Scenario | Trigger | Config Key | Default Model | | ---------------- | ---------------------------------------------------------------------------- | --------------------- | -------------- | -| **Default** | Standard chat | `models.default` | `kimi-k2.6` | -| **Think** | System prompt contains "think", "plan", "reason"; or thinking content blocks | `models.think` | `glm-5.1` | -| **Long Context** | Token count exceeds `context_threshold` | `models.long_context` | `minimax-m2.7` | -| **Background** | File read, directory list, grep patterns | `models.background` | `qwen3.5-plus` | - -Routing priority: **Long Context** > **Think** > **Background** > **Default** +| **Default** | Standard chat | `models.default` | `deepseek-v4-pro` | +| **Complex** | Architectural keywords or tool-heavy operations | `models.complex` | `deepseek-v4-pro` | +| **Think** | System prompt contains "think", "plan", "reason"; or thinking content blocks | `models.think` | `glm-5.2` | +| **Long Context** | Token count exceeds `context_threshold` (default 100K) | `models.long_context` | `minimax-m3` | +| **Vision** | Latest user message contains an image | `models.vision` | (not preconfigured) | +| **Background** | File read, directory list, grep patterns | `models.background` | `deepseek-v4-flash` | + +Routing priority: **Long Context** > **Vision** > **Complex** > **Think** > **Background** > **Default** ## Cost-Based Routing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 179ebf79..5067d521 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,10 +3,19 @@ ## Prerequisites - [Go](https://go.dev/dl/) 1.25.0 or later -- [golangci-lint](https://golangci-lint.run/usage/install/) (for linting) - [Git](https://git-scm.com/) - [Make](https://www.gnu.org/software/make/) (build automation) +Optional but recommended: + +- [golangci-lint](https://golangci-lint.run/usage/install/) — used by the repo's + `pre-push` hook (`scripts/git-hooks/pre-push`, installed via + `scripts/install-hooks.sh`). The hook runs `golangci-lint run --timeout 5m` + when the binary is on your `PATH` and skips the step with a warning when it + isn't. There is no `.golangci.yml` in the repo, so its default linter set + applies. CI and `make lint` do **not** run golangci-lint — `make lint` is a + `gofmt` check plus `go vet`. + ## Getting Started 1. Fork and clone the repository: @@ -43,7 +52,10 @@ When your PR is merged to `main`, a beta release is automatically created: - **Trigger:** Push to `main` branch -- **Version:** `vX.Y.Z-beta-YYYYMMDD-HHMMSS` (auto-generated) +- **Version:** `v{UPCOMING}-beta.{N}` (auto-generated) — `{UPCOMING}` is the latest + production version with the patch incremented, `{N}` is a sequential counter + that resets to 1 once that version ships as stable. Example: with `v0.6.3` + stable, betas are `v0.6.4-beta.1`, `v0.6.4-beta.2`, … - **GitHub Release:** Marked as prerelease - **Testing:** Download and test before reporting issues diff --git a/Makefile b/Makefile index 8c06f8fb..7c675323 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-ui run test clean install dist lint vet docker-up docker-stop +.PHONY: build build-ui run test clean install dist rpm lint vet docker-up docker-stop # Build variables VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") @@ -111,3 +111,29 @@ dist: clean @echo "" @echo "Built binaries:" @ls -lh dist/ + +# ── RPM Packaging (Fedora / RHEL) ────────────────────────────────── +# Mirrors the CI job. Requires nfpm: +# go install github.com/goreleaser/nfpm/v2/cmd/nfpm@$(NFPM_VERSION) +NFPM_VERSION = v2.47.0 +RPM_VERSION ?= $(patsubst v%,%,$(VERSION)) + +rpm: + @command -v nfpm >/dev/null || { \ + echo "nfpm not found. Install with:"; \ + echo " go install github.com/goreleaser/nfpm/v2/cmd/nfpm@$(NFPM_VERSION)"; \ + exit 1; \ + } + @mkdir -p dist + @for arch in amd64 arm64; do \ + echo " → linux/$$arch"; \ + CGO_ENABLED=0 GOOS=linux GOARCH=$$arch \ + go build -ldflags "$(RELEASE_LDFLAGS)" \ + -o "dist/$(BINARY)_linux-$$arch" $(CMD); \ + NFPM_VERSION="$(RPM_VERSION)" \ + NFPM_ARCH="$$arch" \ + NFPM_BINARY="dist/$(BINARY)_linux-$$arch" \ + nfpm package --config packaging/nfpm.yaml --packager rpm --target dist/; \ + done + @echo "" + @ls -lh dist/*.rpm diff --git a/README.md b/README.md index d1084739..a8be13cb 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,10 @@ export ANTHROPIC_AUTH_TOKEN=unused claude ``` +**Fedora / RHEL:** each release ships `x86_64` and `aarch64` RPMs — +`sudo dnf install https://github.com/routatic/proxy/releases/download/vX.Y.Z/routatic-proxy-X.Y.Z-1.x86_64.rpm`. +See [docs/fedora-setup.md](docs/fedora-setup.md) for package contents and the systemd user service. + See [INSTALLATION.md](INSTALLATION.md) for Homebrew, Scoop, Docker, and build-from-source options. Prefer a GUI for switching providers? routatic-proxy works with [CC-Switch](https://github.com/farion1231/cc-switch) — see [Using with CC-Switch](CONFIGURATION.md#using-with-cc-switch). diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index b50157eb..067eab65 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -19,7 +19,7 @@ The project uses a dual release channel system: | Channel | Trigger | Branch | Version Format | GitHub Release Type | |---------|---------|--------|----------------|---------------------| -| **Beta** | Automatic on merge | `main` | `v{prod-version}-beta-{timestamp}` | Prerelease | +| **Beta** | Automatic on merge | `main` | `v{upcoming-version}-beta.{N}` | Prerelease | | **Production** | Manual via `workflow_dispatch` | `releases` | `vX.Y.Z` (user specified) | Stable | ### Key Differences @@ -31,17 +31,17 @@ The project uses a dual release channel system: ### Beta Versions -Format: `v{prod-version}-beta-{timestamp}` +Format: `v{upcoming-version}-beta.{N}` -- `prod-version`: The current production version (e.g., `v1.2.3`) -- `timestamp`: UTC timestamp in format `YYYYMMDD-HHMMSS` +- `upcoming-version`: The latest production version with the **patch incremented** — the version this beta is working toward (e.g., stable `v0.6.3` → upcoming `v0.6.4`) +- `N`: Sequential counter, `max(existing counters for this upcoming version) + 1`, minimum 1. It resets to 1 once the upcoming version ships as stable. -**Example:** `v1.2.3-beta-20260712-143000` +**Example:** stable `v0.6.3` → `v0.6.4-beta.1`, then `v0.6.4-beta.2`, … until `v0.6.4` ships → `v0.6.5-beta.1` The beta version is automatically generated by the `.github/scripts/get-versions.sh` script, which: -1. Detects the latest production version from git tags -2. Generates a UTC timestamp -3. Combines them into the beta version format +1. Detects the latest production version from tags on the `releases` branch +2. Increments the patch to get the upcoming version +3. Scans existing `v{UPCOMING}-beta.*` tags and picks the next counter ### Production Versions @@ -81,13 +81,22 @@ Each beta release includes: - `routatic-proxy_windows-amd64.exe` - Windows Intel binary - `routatic-proxy_windows-arm64.exe` - Windows ARM64 binary - `RoutaticProxy.dmg` - macOS installer package -- `checksums.txt` - SHA256 checksums for all binaries +- `routatic-proxy-{version}-1.x86_64.rpm` - Fedora/RHEL package (Intel) +- `routatic-proxy-{version}-1.aarch64.rpm` - Fedora/RHEL package (ARM64) +- `checksums.txt` - SHA256 checksums for all binaries and RPMs + +The RPMs are built with `nfpm` inside the `release` job and included in the same +atomic `gh release create` call as everything else, because immutable releases +reject assets added after the release exists. See `packaging/nfpm.yaml` and +`make rpm`. Beta RPMs use an RPM-native prerelease version (`0.6.4~beta.1-1`) +so they sort below the eventual stable package. ### Docker Tags for Beta Beta releases are tagged as: -- `ghcr.io/routatic/proxy:{beta_tag}` (e.g., `v1.2.3-beta-20260712-143000`) -- `ghcr.io/routatic/proxy:beta-{prod_version}` (e.g., `beta-1.2.3`) +- `ghcr.io/routatic/proxy:{beta_tag}` (e.g., `v0.6.4-beta.1`) +- `ghcr.io/routatic/proxy:beta-{prod_version}` — the latest *stable* version, tag included (e.g., `beta-v0.6.3`) +- `ghcr.io/routatic/proxy:beta` (rolling pointer to the newest beta) ## Production Releases @@ -191,7 +200,7 @@ To verify a beta release was created: ```bash # List recent beta tags -git tag -l "v*-beta-*" --sort=-version:refname | head -10 +git tag -l "v*-beta.*" --sort=-version:refname | head -10 # Or check GitHub CLI gh release list --repo routatic/proxy --limit 20 @@ -269,7 +278,7 @@ gh run list --workflow=beta-release.yml --limit 10 #### Issue: Beta version shows wrong production version -**Symptoms:** Beta tag shows `v0.0.0-beta-...` instead of actual version. +**Symptoms:** Beta tag shows `v0.0.1-beta.1` (derived from the `v0.0.0` fallback) instead of actual version. **Diagnosis:** ```bash @@ -477,7 +486,7 @@ Update Scoop bucket git tag -l --sort=-version:refname # List beta tags only -git tag -l "v*-beta-*" --sort=-version:refname +git tag -l "v*-beta.*" --sort=-version:refname # List production tags only git tag -l "v[0-9]*.[0-9]*.[0-9]*" --sort=-version:refname diff --git a/configs/config.example.json b/configs/config.example.json index e515f236..096e1e95 100644 --- a/configs/config.example.json +++ b/configs/config.example.json @@ -1,7 +1,9 @@ { "api_key": "${ROUTATIC_PROXY_API_KEY}", + "api_keys": [], "host": "127.0.0.1", "port": 3456, + "update_channel": "stable", "hot_reload": false, "enable_streaming_scenario_routing": false, "respect_requested_model": false, @@ -358,6 +360,33 @@ "streaming_timeout_ms": 600000 }, + "openrouter": { + "base_url": "https://openrouter.ai/api/v1", + "api_key": "", + "api_keys": [], + "timeout_ms": 300000, + "stream_timeout_ms": 60000, + "streaming_timeout_ms": 600000 + }, + + "catalog": { + "enabled": true, + "max_age_hours": 24, + "source_url": "https://models.dev/catalog.json" + }, + + "storage": { + "database_path": "~/.local/share/routatic-proxy/data.db", + "retention_days": 7, + "vacuum_on_startup": false, + "wal_enabled": true + }, + + "debug": { + "capture_enabled": false, + "capture_dir": "~/.config/routatic-proxy/debug/" + }, + "opencode_zen": { "base_url": "https://opencode.ai/zen/v1/chat/completions", "anthropic_base_url": "https://opencode.ai/zen/v1/messages", diff --git a/docs/fedora-setup.md b/docs/fedora-setup.md index 69bb44e3..616f44f2 100644 --- a/docs/fedora-setup.md +++ b/docs/fedora-setup.md @@ -10,7 +10,9 @@ This guide covers setting up, configuring, and using routatic-proxy on Fedora 44 - [Running the Proxy](#running-the-proxy) - [Configuring Claude Code](#configuring-claude-code) - [Systemd Service Setup](#systemd-service-setup) +- [Auto-start on Login](#auto-start-on-login) - [Troubleshooting](#troubleshooting) +- [Updating](#updating) --- @@ -32,7 +34,69 @@ Before installing routatic-proxy, ensure you have: ## Installation Methods -### Method 1: Download Pre-built Binary (Recommended) +### Method 1: RPM Package with dnf (Recommended) + +Every release publishes RPMs for `x86_64` and `aarch64`. Installing the RPM puts +the binary at `/usr/bin/routatic-proxy`, ships an optional systemd **user** unit, +and lets `dnf` handle upgrades and removal. + +```bash +# Pick the version you want from the Releases page, e.g. v0.5.3 +VERSION=0.5.3 + +# x86_64 (most common) +sudo dnf install "https://github.com/routatic/proxy/releases/download/v${VERSION}/routatic-proxy-${VERSION}-1.x86_64.rpm" + +# aarch64 (ARM64) +sudo dnf install "https://github.com/routatic/proxy/releases/download/v${VERSION}/routatic-proxy-${VERSION}-1.aarch64.rpm" + +# Verify installation +routatic-proxy --version +``` + +To always grab the newest release without hardcoding a version: + +```bash +RPM_URL=$(curl -fsSL https://api.github.com/repos/routatic/proxy/releases/latest \ + | grep -o 'https://[^"]*\.'"$(uname -m)"'\.rpm') +sudo dnf install "$RPM_URL" +``` + +Beta channel RPMs use a `~beta.N` version suffix (for example +`routatic-proxy-0.5.3~beta.1-1.x86_64.rpm`), which RPM sorts *below* the matching +stable `0.5.3` release — so a later `dnf upgrade` moves you onto stable cleanly. + +What the package installs: + +| Path | Purpose | +|------|---------| +| `/usr/bin/routatic-proxy` | The binary (`/usr/bin/oc-go-cc` is a symlink to it) | +| `/etc/routatic-proxy/config.json` | System-wide config template, `%config(noreplace)` — your edits survive upgrades | +| `/usr/lib/systemd/user/routatic-proxy.service` | Optional systemd user unit, disabled by default | +| `/usr/share/doc/routatic-proxy/` | README, configuration, troubleshooting, this guide | +| `/usr/share/licenses/routatic-proxy/LICENSE` | AGPL-3.0-only license text | + +Removing it: + +```bash +sudo dnf remove routatic-proxy +``` + +#### A Note on Signatures + +The published RPMs are **not GPG-signed yet**, so `dnf` will not be able to verify +their provenance. Until signing lands, verify the download against the +`checksums.txt` asset attached to the same release: + +```bash +curl -fsSLO "https://github.com/routatic/proxy/releases/download/v${VERSION}/checksums.txt" +sha256sum -c checksums.txt --ignore-missing +``` + +The plan is to sign releases with a project GPG key (and likely publish a COPR +repository so `dnf` can resolve upgrades directly). Neither exists today. + +### Method 2: Download Pre-built Binary Download the latest Linux binary from the [Releases page](https://github.com/routatic/proxy/releases): @@ -51,7 +115,7 @@ sudo mv routatic-proxy /usr/local/bin/ routatic-proxy --version ``` -### Method 2: Build from Source +### Method 3: Build from Source Building from source requires Go 1.25.0 or later. @@ -99,7 +163,7 @@ sudo make install routatic-proxy --version ``` -### Method 3: Docker +### Method 4: Docker Install Docker on Fedora 44: @@ -273,9 +337,51 @@ Claude Code will now route all requests through routatic-proxy to your configure ## Systemd Service Setup -For production use, run routatic-proxy as a systemd service. +routatic-proxy is a per-user proxy listening on loopback and reading its config +from `~/.config/routatic-proxy`, so the supported unit is a **systemd user +service**, not a machine-level daemon. + +### Option A: Packaged User Service (Recommended) -### Create Service File +The RPM ships `/usr/lib/systemd/user/routatic-proxy.service`, disabled by default. +Opt in per user — no root needed after installation: + +```bash +# Optional: put your API key (and any other overrides) in the unit's env file +mkdir -p ~/.config/routatic-proxy +echo 'ROUTATIC_PROXY_API_KEY=sk-opencode-your-key-here' > ~/.config/routatic-proxy/env +chmod 600 ~/.config/routatic-proxy/env + +# Enable and start for your user +systemctl --user enable --now routatic-proxy + +# Check status and logs +systemctl --user status routatic-proxy +journalctl --user -u routatic-proxy -f +``` + +Managing it: + +```bash +systemctl --user restart routatic-proxy +systemctl --user stop routatic-proxy +systemctl --user disable routatic-proxy +``` + +By default a user service stops when your last session ends. To keep the proxy +running after logout: + +```bash +sudo loginctl enable-linger "$USER" +``` + +### Option B: System-wide Service (Manual) + +Only needed if the proxy must serve something other than your own login session +(for example a shared host). This unit is not shipped by the package — write it +yourself. + +#### Create Service File ```bash sudo nano /etc/systemd/system/routatic-proxy.service @@ -293,7 +399,7 @@ Type=simple User=%USER% Group=%USER% WorkingDirectory=/home/%USER% -ExecStart=/usr/local/bin/routatic-proxy serve +ExecStart=/usr/bin/routatic-proxy serve Restart=on-failure RestartSec=5 @@ -313,9 +419,11 @@ PrivateTmp=true WantedBy=multi-user.target ``` -Replace `%USER%` with your actual username. +Replace `%USER%` with your actual username. If you installed from a tarball or +source rather than the RPM, point `ExecStart` at wherever the binary actually +lives (for example `/usr/local/bin/routatic-proxy`). -### Enable and Start Service +#### Enable and Start Service ```bash # Reload systemd daemon @@ -334,7 +442,7 @@ sudo systemctl status routatic-proxy journalctl -u routatic-proxy -f ``` -### Managing the Service +#### Managing the Service ```bash # Stop @@ -467,6 +575,21 @@ routatic-proxy logs ## Updating +### RPM Update + +There is no COPR repository yet, so point `dnf` at the new release's RPM: + +```bash +VERSION=0.5.4 +sudo dnf upgrade "https://github.com/routatic/proxy/releases/download/v${VERSION}/routatic-proxy-${VERSION}-1.$(uname -m).rpm" +``` + +Your `/etc/routatic-proxy/config.json` edits are preserved; if the packaged +template changed, the new version lands beside it as `config.json.rpmnew`. + +> Don't use `routatic-proxy update` on an RPM install — it replaces the binary +> behind `dnf`'s back and leaves the package database out of sync. + ### Binary Update ```bash diff --git a/docs/howto-custom-routing.md b/docs/howto-custom-routing.md index 6ac55cff..7368c2a8 100644 --- a/docs/howto-custom-routing.md +++ b/docs/howto-custom-routing.md @@ -8,13 +8,45 @@ Each request is classified into a scenario, which maps to a model: | Scenario | Trigger | Default Model | |----------|---------|---------------| -| `default` | No special patterns detected | Kimi K2.6 | -| `complex` | Architectural keywords, tool operations | GLM-5.1 | -| `think` | Reasoning keywords in system prompt | GLM-5 | -| `background` | Simple read-only ops (ls, cat, "what is") | Qwen3.5 Plus | -| `long_context` | Token count > threshold (default 100K) | MiniMax M2.5 | -| `vision` | Request contains images | (must configure) | -| `fast` | Streaming requests (when scenario routing disabled) | Qwen3.6 Plus | +| `default` | No special patterns detected | `deepseek-v4-pro` | +| `complex` | Architectural keywords, tool operations | `deepseek-v4-pro` | +| `think` | Reasoning keywords in system prompt | `glm-5.2` | +| `background` | Simple read-only ops (ls, cat, "what is") | `deepseek-v4-flash` | +| `long_context` | Token count > threshold (default 100K) | `minimax-m3` | +| `vision` | Latest user message contains an image, simple intent | (must configure) | +| `vision_complex` | Image request whose text also shows complex intent | (must configure) | +| `vision_long_context` | Image request above the long-context threshold | (must configure) | +| `fast` | Streaming requests (when scenario routing disabled) | `deepseek-v4-flash` | + +"Default Model" is what `routatic-proxy init` writes +(`cmd/routatic-proxy/templates/default_config.json`). The three `vision*` +scenarios have no entry in the shipped config, so image requests fall through to +the ordinary scenario models until you add them: + +```json +{ + "models": { + "vision": { + "provider": "opencode-go", + "model_id": "qwen3.7-plus", + "temperature": 0.7, + "max_tokens": 4096 + }, + "vision_complex": { + "provider": "opencode-go", + "model_id": "kimi-k3", + "temperature": 0.7, + "max_tokens": 8192 + }, + "vision_long_context": { + "provider": "opencode-go", + "model_id": "kimi-k3", + "temperature": 0.7, + "max_tokens": 16384 + } + } +} +``` ## Override Scenario Models diff --git a/docs/models.md b/docs/models.md index 86713514..ade620d2 100644 --- a/docs/models.md +++ b/docs/models.md @@ -6,13 +6,30 @@ Complete model reference for routatic-proxy including OpenCode Go, Zen, OpenRout ## OpenCode Go Models -| Model | Context | Best For | -| ------------------ | ------------ | --------------------------------------------- | -| **GLM-5.2** | ~200K tokens | Critical architecture, production code review | -| **Kimi K3** | ~1M tokens | Latest Kimi, code + agentic, 131K max output | -| **Kimi K2.7 Code** | ~256K tokens | Large code generation, 32K max output | -| **Qwen3.7 Plus** | ~128K tokens | General coding, better quality than Qwen3.6 | -| **Qwen3.7 Max** | ~128K tokens | Complex coding, Qwen's best quality | +Context and max-output values come from the built-in registry (`modelMetadata` in `internal/config/model_registry.go`). + +| Model | Context | Max output | Vision | Best For | +| ------------------------- | ------------ | ---------- | ------ | ----------------------------------------------- | +| **DeepSeek V4 Pro** | ~1M tokens | 8192 | no | Default + complex scenarios in the shipped config | +| **DeepSeek V4 Flash** | ~1M tokens | 4096 | no | Background / fast scenarios, cheap and quick | +| **GLM-5.2** | ~200K tokens | 8192 | no | Critical architecture, production code review | +| **GLM-5.1** | ~200K tokens | 8192 | no | Complex patterns, tool-heavy operations | +| **Kimi K3** | ~1M tokens | 131072 | yes | Latest Kimi, code + agentic, huge max output | +| **Kimi K2.7 Code** | ~256K tokens | 32768 | yes | Large code generation | +| **Kimi K2.6** | ~256K tokens | 8192 | yes | General purpose, common fallback | +| **Kimi K2.5** | ~256K tokens | 8192 | yes | Previous-generation Kimi fallback | +| **MiniMax M3** | ~1M tokens | 128000 | no | Long-context scenario, very large output budget | +| **MiniMax M2.7** | ~200K tokens | 8192 | no | Previous MiniMax generation | +| **MiniMax M2.5** | ~200K tokens | 4096 | no | Older MiniMax generation | +| **MiMo V2.5 Pro** | ~1M tokens | 16384 | no | Step-by-step reasoning, larger output | +| **MiMo V2.5** | ~1M tokens | 8192 | no | Step-by-step reasoning | +| **MiMo V2 Omni** | ~1M tokens | 8192 | yes | Multimodal MiMo | +| **Qwen3.7 Max** | ~1M tokens | 8192 | yes | Complex coding, Qwen's best quality | +| **Qwen3.7 Plus** | ~1M tokens | 8192 | yes | General coding, better quality than Qwen3.6 | +| **Qwen3.6 Plus** | ~1M tokens | 8192 | yes | Streaming fallback | +| **Qwen3.5 Plus** | ~1M tokens | 8192 | yes | Simple read-only ops | + +All registry models support tool use. The registry is provider-agnostic — the free variants (`deepseek-v4-flash-free`, `mimo-v2.5-free`) are wired to Zen in the shipped config. See [MODELS.md](../MODELS.md) for the complete model list including costs and routing recommendations. @@ -35,21 +52,7 @@ See [MODELS.md](../MODELS.md#opencodes-zen) for the full Zen model list. OpenRouter provides unified access to 100+ models from multiple providers through a single API endpoint. -### Popular Models - -| Model | Provider | Context Window | Input Cost ($/M) | Output Cost ($/M) | Best For | -|-------|----------|----------------|------------------|-------------------|----------| -| **Claude 3.5 Sonnet** | Anthropic | 200K | $3.00 | $15.00 | Complex reasoning, coding, analysis | -| **Claude 3 Opus** | Anthropic | 200K | $15.00 | $75.00 | Maximum quality, difficult tasks | -| **GPT-4o** | OpenAI | 128K | $2.50 | $10.00 | General purpose, vision tasks | -| **GPT-4o Mini** | OpenAI | 128K | $0.15 | $0.60 | Cost-effective, high volume | -| **Gemini 2.5 Pro** | Google | 1M | $1.25 | $10.00 | Long context, coding, reasoning | -| **Gemini 2.0 Flash** | Google | 1M | $0.10 | $0.40 | Fast responses, cost efficiency | -| **Llama 3.3 70B** | Meta | 128K | $0.12 | $0.30 | Open source, customizable | -| **Mistral Large** | Mistral | 128K | $2.00 | $6.00 | European provider, GDPR compliant | -| **DeepSeek V3** | DeepSeek | 64K | $0.07 | $1.10 | Cost efficiency, coding | - -See [docs/openrouter.md](./openrouter.md) for complete OpenRouter setup and configuration. +Model IDs, context windows, and pricing change often, so this page does not keep its own copy. See [openrouter.md](./openrouter.md) ("Model Examples") for the model-key list plus complete OpenRouter setup and configuration, and [openrouter.ai/models](https://openrouter.ai/models) for authoritative live pricing and context limits. --- diff --git a/docs/reference-api.md b/docs/reference-api.md index 68649b63..42d9ac03 100644 --- a/docs/reference-api.md +++ b/docs/reference-api.md @@ -129,6 +129,27 @@ Returns compact status for TUI integration (statusline, tmux bar). } ``` +### Analytics endpoints (SQLite only) + +These three routes are registered **only when SQLite storage is available** — that +is, when the `storage` block is configured and the database opens successfully +(`internal/server/server.go`). Without storage they are absent and return `404`. + +All three accept an optional `days` query parameter (positive integer, default +`30`; invalid values fall back to `30`). + +| Route | Returns | +|-------|---------| +| `GET /api/analytics/summary` | `summary` (token KPIs), `models` (per-model breakdown), `providers` (per-provider breakdown), `generated_at` | +| `GET /api/analytics/tokens/trend` | `days` plus `trend` — daily token totals | +| `GET /api/analytics/latency` | `days` plus `stats` — per-model latency statistics | + +**Example:** + +```bash +curl 'http://127.0.0.1:3456/api/analytics/summary?days=7' +``` + ## Error Responses Errors follow Anthropic's error format: @@ -183,7 +204,3 @@ data: {"type":"message_stop"} ## Rate Limiting The proxy applies per-IP rate limiting (default: 100 requests/minute). Rate-limited requests receive HTTP 429. - -## Request Deduplication - -Optional request deduplication (`request_dedup` in config) prevents processing identical concurrent requests. Deduplicated requests receive HTTP 200 with no body. diff --git a/docs/zh/CONFIGURATION.md b/docs/zh/CONFIGURATION.md index aed2b54b..f410fe94 100644 --- a/docs/zh/CONFIGURATION.md +++ b/docs/zh/CONFIGURATION.md @@ -2,6 +2,8 @@ [English](../../CONFIGURATION.md) | **中文** +> **注意:** 本翻译可能落后于英文原版。如有疑问,请以 [CONFIGURATION.md](../../CONFIGURATION.md)(英文)为准。 + ## 配置文件 位置:`~/.config/routatic-proxy/config.json` diff --git a/docs/zh/INSTALLATION.md b/docs/zh/INSTALLATION.md index b3d6b69e..0212a83f 100644 --- a/docs/zh/INSTALLATION.md +++ b/docs/zh/INSTALLATION.md @@ -2,6 +2,8 @@ [English](../../INSTALLATION.md) | **中文** +> **注意:** 本翻译可能落后于英文原版。如有疑问,请以 [INSTALLATION.md](../../INSTALLATION.md)(英文)为准。 + ## Homebrew(macOS 和 Linux) ```bash diff --git a/docs/zh/MODELS.md b/docs/zh/MODELS.md index 43a44b75..f9bc560c 100644 --- a/docs/zh/MODELS.md +++ b/docs/zh/MODELS.md @@ -2,6 +2,8 @@ [English](../../MODELS.md) | **中文** +> **注意:** 本翻译可能落后于英文原版。如有疑问,请以 [MODELS.md](../../MODELS.md)(英文)为准。 + OpenCode Go 和 Zen 模型的综合指南,包括能力、成本和路由建议。 **来源:** [OpenCode Go 文档](https://opencode.ai/docs/go/) | [OpenCode Zen 文档](https://opencode.ai/docs/zen/) @@ -551,5 +553,5 @@ OpenCode Go 限制: ## 另请参阅 - [OpenCode Go 文档](https://opencode.ai/docs/go/) -- [routatic-proxy 配置](../configs/config.example.json) -- [README.md](../README.md) 获取设置说明 +- [routatic-proxy 配置](../../configs/config.example.json) +- [README.md](../../README.md) 获取设置说明 diff --git a/docs/zh/TROUBLESHOOTING.md b/docs/zh/TROUBLESHOOTING.md index 9d04c700..e95a840b 100644 --- a/docs/zh/TROUBLESHOOTING.md +++ b/docs/zh/TROUBLESHOOTING.md @@ -2,6 +2,8 @@ [English](../../TROUBLESHOOTING.md) | **中文** +> **注意:** 本文档与英文原版内容存在差异(当前中文版包含英文版尚未收录的章节)。两个版本请交叉参考:[TROUBLESHOOTING.md](../../TROUBLESHOOTING.md)(英文)。 + ## Windows Scoop 后台模式 在 Windows 上,`routatic-proxy serve -b` 使用原生 Windows 进程 API,并保持 Scoop shim 路径不变。这意味着后台模式不需要 `nohup` 或类似 Unix 的 shell,Scoop 提供的环境变量继续工作。 diff --git a/internal/config/atomic.go b/internal/config/atomic.go index f90f6d17..74d5ddd0 100644 --- a/internal/config/atomic.go +++ b/internal/config/atomic.go @@ -83,6 +83,11 @@ func (a *AtomicConfig) Reload() error { } // OnReload registers a callback that will be invoked after each successful reload. +// +// Callbacks run BEFORE the new config is published, so that they can still +// mutate it (e.g. a port override). A callback must therefore read the *Config +// it is handed rather than calling Get(), which still returns the previous +// config until every callback has returned. func (a *AtomicConfig) OnReload(fn func(*Config)) { a.mu.Lock() defer a.mu.Unlock() diff --git a/internal/config/watcher.go b/internal/config/watcher.go index 88a58091..84925c91 100644 --- a/internal/config/watcher.go +++ b/internal/config/watcher.go @@ -17,6 +17,22 @@ import ( // handle editors that save by renaming/creating a new file. It also listens for // SIGHUP to allow manual reload triggers on Unix systems. func WatchConfig(ctx context.Context, atomic *AtomicConfig) error { + return WatchConfigWithReady(ctx, atomic, nil) +} + +// WatchConfigWithReady behaves exactly like WatchConfig, but additionally +// signals ready once the filesystem watch has been registered. +// +// The signal fires immediately after the watch on the config file's directory is +// established — that is, it means "changes from this point on will be observed". +// It does not mean a reload has happened, and it says nothing about the config +// having been re-read: no reload occurs until an actual file change arrives. +// Writes that complete before the signal may be missed entirely, so callers that +// need a change to be observed must wait for ready before writing. +// +// The send is non-blocking, so a nil or unbuffered-and-unread channel never +// stalls the watcher. Use a buffered channel (capacity 1) to receive it reliably. +func WatchConfigWithReady(ctx context.Context, atomic *AtomicConfig, ready chan<- struct{}) error { path := atomic.Path() absPath, err := filepath.Abs(path) if err != nil { @@ -41,6 +57,14 @@ func WatchConfig(ctx context.Context, atomic *AtomicConfig) error { slog.Info("config watcher started", "path", absPath) + // The watch is live: changes from here on will be observed. + if ready != nil { + select { + case ready <- struct{}{}: + default: + } + } + // SIGHUP handler for manual reload triggers sighup := make(chan os.Signal, 1) signal.Notify(sighup, syscall.SIGHUP) diff --git a/internal/config/watcher_test.go b/internal/config/watcher_test.go index 403f794d..54edf4a6 100644 --- a/internal/config/watcher_test.go +++ b/internal/config/watcher_test.go @@ -24,38 +24,68 @@ func TestWatchConfig_DetectsFileChange(t *testing.T) { at := NewAtomicConfig(cfg, path) - // Watch for reload via callback instead of polling. - reloaded := make(chan struct{}, 1) - at.OnReload(func(_ *Config) { + // Watch for reload via callback instead of polling. The callback receives the + // freshly loaded config; AtomicConfig.Reload invokes callbacks *before* it + // swaps the pointer in, so at.Get() is not guaranteed to be updated yet when + // the callback fires. Assert on the config handed to the callback and wait + // separately for the swap to become visible. + reloaded := make(chan *Config, 1) + at.OnReload(func(newCfg *Config) { select { - case reloaded <- struct{}{}: + case reloaded <- newCfg: default: } }) - // Start watcher in background + // Start watcher in background. ready closes the gap that made this test flaky: + // the fsnotify watch is registered asynchronously, so a write issued before + // registration produces no event at all and is lost. + ready := make(chan struct{}, 1) go func() { - if err := WatchConfig(t.Context(), at); err != nil && err != context.Canceled { + if err := WatchConfigWithReady(t.Context(), at, ready); err != nil && err != context.Canceled { t.Logf("WatchConfig returned: %v", err) } }() - // Give watcher time to set up - time.Sleep(200 * time.Millisecond) + select { + case <-ready: + case <-time.After(10 * time.Second): + t.Fatal("config watcher never became ready") + } - // Modify config file + // The watch is live, so a single write is guaranteed to be observed. updatedJSON := `{"api_key": "watcher-updated"}` if err := os.WriteFile(path, []byte(updatedJSON), 0644); err != nil { t.Fatalf("failed to write updated config: %v", err) } - // Wait for reload notification with timeout select { - case <-reloaded: - if at.Get().APIKey != "watcher-updated" { - t.Errorf("after reload, APIKey = %q, want %q", at.Get().APIKey, "watcher-updated") + case newCfg := <-reloaded: + if newCfg.APIKey != "watcher-updated" { + t.Errorf("reloaded config APIKey = %q, want %q", newCfg.APIKey, "watcher-updated") } - case <-time.After(5 * time.Second): + waitForAPIKey(t, at, "watcher-updated") + case <-time.After(10 * time.Second): t.Fatal("config was not reloaded after file change") } } + +// waitForAPIKey waits until the atomically published config exposes the expected +// API key. The pointer swap happens right after reload callbacks return, so this +// resolves almost immediately; the bound only guards against a missing swap. +func waitForAPIKey(t *testing.T, at *AtomicConfig, want string) { + t.Helper() + + deadline := time.Now().Add(5 * time.Second) + for { + got := at.Get().APIKey + if got == want { + return + } + if time.Now().After(deadline) { + t.Errorf("after reload, APIKey = %q, want %q", got, want) + return + } + time.Sleep(5 * time.Millisecond) + } +} diff --git a/package-lock.json b/package-lock.json index 2a3c2fe4..2d11a023 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "proxy", "devDependencies": { "tailwindcss": "^3.4.19" } @@ -480,9 +481,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -576,9 +577,9 @@ } }, "node_modules/postcss": { - "version": "8.5.18", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz", - "integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -596,7 +597,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 00000000..3c698711 --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,86 @@ +# nfpm configuration for routatic-proxy RPM packages. +# +# Driven entirely by environment variables so the same file produces every +# architecture: +# +# NFPM_VERSION version without the leading "v" (e.g. 0.5.3 or 0.5.3-beta.1) +# NFPM_ARCH nfpm/Go arch name: amd64 -> x86_64, arm64 -> aarch64 +# NFPM_BINARY path to the prebuilt linux binary for that arch +# +# Build with: +# nfpm package --config packaging/nfpm.yaml --packager rpm --target dist/ +# +# See docs: https://nfpm.goreleaser.com/configuration/ +name: routatic-proxy +arch: ${NFPM_ARCH} +platform: linux +version: ${NFPM_VERSION} +# semver schema: a prerelease suffix (-beta.N) is folded into the RPM Release +# field so beta packages sort *below* the matching stable release. +version_schema: semver +release: "1" +maintainer: samuel tuyizere +vendor: Routatic +homepage: https://github.com/routatic/proxy +license: AGPL-3.0-only +description: | + Proxy Claude Code requests to OpenCode Go API. + routatic-proxy sits between Claude Code and OpenCode, translating Anthropic + API requests to the upstream provider format, routing them to the cheapest + capable model, and translating responses back. Includes a local dashboard + for usage metrics, request history, and live config editing. + +rpm: + summary: Proxy Claude Code requests to OpenCode Go API + group: Applications/Internet + compression: xz + +contents: + # expand: true is required for ${...} substitution inside contents entries. + - src: ${NFPM_BINARY} + dst: /usr/bin/routatic-proxy + expand: true + file_info: + mode: 0755 + + # Legacy command name, kept in step with the Homebrew/Scoop packages. + - src: /usr/bin/routatic-proxy + dst: /usr/bin/oc-go-cc + type: symlink + + # Per-user systemd unit. Enable with: + # systemctl --user enable --now routatic-proxy + # A user unit (not a system one) is deliberate: the proxy reads its config + # from ~/.config/routatic-proxy and only listens on loopback, so it belongs + # to the invoking user rather than a system service account. + - src: packaging/systemd/routatic-proxy.service + dst: /usr/lib/systemd/user/routatic-proxy.service + file_info: + mode: 0644 + + # System-wide config template. Marked noreplace so a package upgrade never + # overwrites local edits (the .rpmnew file is written alongside instead). + # routatic-proxy reads ~/.config/routatic-proxy/config.json by default; point + # it at this file with `routatic-proxy serve -c /etc/routatic-proxy/config.json`. + - src: configs/config.example.json + dst: /etc/routatic-proxy/config.json + type: "config|noreplace" + file_info: + mode: 0644 + + - src: LICENSE + dst: /usr/share/licenses/routatic-proxy/LICENSE + type: license + + - src: README.md + dst: /usr/share/doc/routatic-proxy/README.md + type: doc + - src: CONFIGURATION.md + dst: /usr/share/doc/routatic-proxy/CONFIGURATION.md + type: doc + - src: TROUBLESHOOTING.md + dst: /usr/share/doc/routatic-proxy/TROUBLESHOOTING.md + type: doc + - src: docs/fedora-setup.md + dst: /usr/share/doc/routatic-proxy/fedora-setup.md + type: doc diff --git a/packaging/systemd/routatic-proxy.service b/packaging/systemd/routatic-proxy.service new file mode 100644 index 00000000..83355eea --- /dev/null +++ b/packaging/systemd/routatic-proxy.service @@ -0,0 +1,27 @@ +[Unit] +Description=Routatic Proxy Service +Documentation=https://github.com/routatic/proxy +After=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/routatic-proxy serve +Restart=on-failure +RestartSec=5 + +# Optional: put ROUTATIC_PROXY_API_KEY (and any other overrides) here. +# The leading "-" makes the file optional. +EnvironmentFile=-%h/.config/routatic-proxy/env + +# Hardening. The proxy only needs loopback networking and its own config +# directory under $HOME. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectKernelTunables=true +ProtectControlGroups=true +RestrictSUIDSGID=true +ReadWritePaths=%h/.config/routatic-proxy %h/.local/share/routatic-proxy + +[Install] +WantedBy=default.target From 633b1b23b4df6a6759271ff15f464e75e7155350 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Fri, 21 Aug 2026 03:18:11 +0200 Subject: [PATCH 2/5] chore: dynamic routing reasons, strict lint, Linux RPM job, doc sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the four decisions taken after the previous commit. Routing reasons now read the resolved model from config. The Reason strings in scenarios.go hardcoded model names that had drifted (GLM-5.1, Kimi K2.6) because ScenarioResult.Reason had zero production consumers — nothing ever displayed them, so nobody noticed. DetectScenario now explains only why a scenario matched, and ModelRouter appends the model at the point it is actually resolved, covering scenario, cost-based catalog selection, respect_requested_model and both override paths. The routing log line now carries it, and a test fails if a detector reason ever names a model again. Also fixes ScenarioPolicy.Evaluate, which was formatting the scenario name twice as a placeholder instead of using the reason. golangci-lint is now configured rather than implied. Adds a .golangci.yml in v2 schema that passes with zero issues on the current tree: the standard set plus bodyclose, copyloopvar, misspell, nolintlint, whitespace, and revive with an explicit 23-rule list. gosec, errorlint and gocritic were evaluated and rejected — their findings are either inherent to a local CLI or need code changes, and suppressing them wholesale would make the linter meaningless. Adds make lint-strict, pins the CI lint action by SHA, and the committed pre-push hook now picks the config up with no change. make lint stays as the fast gofmt+vet check. RPM packaging moves to a dedicated Linux job so CI can actually inspect the packages. rpm runs on ubuntu-latest before release and hands over the rpm-packages artifact, which still enters the single atomic gh release create — immutable releases forbid adding assets afterwards. Verification asserts name, version, architecture, license, every payload path, the noreplace flag on the config, and that the packaged binary is an executable ELF of the right machine type; it collects all failures before exiting. The same scripts run in ci.yml on every PR, so packaging breaks at review time rather than at release time. Documentation. docs/models.md was a second, thinner copy of MODELS.md introduced by #116; it is now a pointer, because the duplication had already caused real drift — an earlier pass corrected the capability numbers in one file and left the same errors in the other. Those errors are fixed: MiniMax M2.5 is 200K not 1M (so long-context advice now points at MiniMax M3, which is what the shipped config uses), all four Qwen3.x models are 1M not 128K and are vision-capable, Kimi vision was undocumented, gpt-5.5-mini/nano were missing, and a documented fallback chain routed long context through a 200K model. The Chinese translations of CONFIGURATION, MODELS and INSTALLATION are brought up to date with their English originals, and English TROUBLESHOOTING gains the sections that only existed in Chinese. --- .github/scripts/build-rpms.sh | 62 ++++ .github/scripts/verify-rpm.sh | 155 ++++++++++ .github/workflows/beta-release.yml | 133 ++++++-- .github/workflows/ci.yml | 50 ++- .github/workflows/release.yml | 133 ++++++-- .golangci.yml | 76 +++++ CLAUDE.md | 14 +- CONFIGURATION.md | 2 +- CONTRIBUTING.md | 26 +- MODELS.md | 86 +++--- Makefile | 15 +- README.md | 3 +- RELEASE_PROCESS.md | 15 +- TROUBLESHOOTING.md | 38 +++ docs/howto-debug-routing.md | 13 +- docs/models.md | 90 ++---- docs/zh/CONFIGURATION.md | 381 +++++++++++++++++++++-- docs/zh/INSTALLATION.md | 20 +- docs/zh/MODELS.md | 441 +++++++++++++++++++++++---- docs/zh/TROUBLESHOOTING.md | 2 - internal/handlers/messages.go | 1 + internal/router/model_router.go | 32 ++ internal/router/model_router_test.go | 79 +++++ internal/router/policy.go | 2 +- internal/router/scenarios.go | 38 +-- internal/router/scenarios_test.go | 55 +++- packaging/nfpm.yaml | 5 +- 27 files changed, 1642 insertions(+), 325 deletions(-) create mode 100755 .github/scripts/build-rpms.sh create mode 100755 .github/scripts/verify-rpm.sh create mode 100644 .golangci.yml diff --git a/.github/scripts/build-rpms.sh b/.github/scripts/build-rpms.sh new file mode 100755 index 00000000..ff3719ad --- /dev/null +++ b/.github/scripts/build-rpms.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# build-rpms.sh — cross-compile the Linux binaries and package them as RPMs. +# +# Runs on a Linux runner so the resulting packages can be introspected with +# rpm(8) in the same job (see verify-rpm.sh). nfpm is pure Go and needs no +# rpmbuild, but `rpm -qip`/`rpm -qlp` only exist on Linux. +# +# Environment: +# VERSION version without the leading "v" (e.g. 0.6.4 or 0.6.4-beta.1) +# OUTDIR output directory (default: dist) +# NFPM_REF nfpm module version to install (default: v2.47.0) +# +# Produces, in $OUTDIR: +# routatic-proxy_linux-amd64 raw binary +# routatic-proxy_linux-arm64 raw binary +# routatic-proxy--1.x86_64.rpm +# routatic-proxy--1.aarch64.rpm +set -euo pipefail + +: "${VERSION:?VERSION must be set (version without the leading v)}" +OUTDIR="${OUTDIR:-dist}" +NFPM_REF="${NFPM_REF:-v2.47.0}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +mkdir -p "$OUTDIR" + +# Same ldflags the release job uses for every other platform, so the version +# baked into the packaged binary matches the published raw binaries exactly. +LDFLAGS="-X main.version=${VERSION}" + +for ARCH in amd64 arm64; do + echo "Building linux/${ARCH}..." + CGO_ENABLED=0 GOOS=linux GOARCH="$ARCH" \ + go build -ldflags "$LDFLAGS -s -w" \ + -o "${OUTDIR}/routatic-proxy_linux-${ARCH}" \ + ./cmd/routatic-proxy +done + +echo "Installing nfpm ${NFPM_REF}..." +go install "github.com/goreleaser/nfpm/v2/cmd/nfpm@${NFPM_REF}" +NFPM="$(go env GOPATH)/bin/nfpm" + +# nfpm's semver schema turns 0.6.4-beta.1 into RPM version 0.6.4~beta.1, which +# sorts below the matching stable release. Go arch names go in; nfpm maps +# amd64 -> x86_64 and arm64 -> aarch64. +for ARCH in amd64 arm64; do + echo "Packaging ${ARCH}..." + NFPM_VERSION="$VERSION" \ + NFPM_ARCH="$ARCH" \ + NFPM_BINARY="${OUTDIR}/routatic-proxy_linux-${ARCH}" \ + "$NFPM" package --config packaging/nfpm.yaml --packager rpm --target "$OUTDIR/" +done + +COUNT=$(find "$OUTDIR" -maxdepth 1 -name '*.rpm' | wc -l | tr -d ' ') +if [ "$COUNT" -ne 2 ]; then + echo "::error::Expected 2 RPMs in ${OUTDIR}, found ${COUNT}" + exit 1 +fi + +ls -lh "$OUTDIR"/*.rpm diff --git a/.github/scripts/verify-rpm.sh b/.github/scripts/verify-rpm.sh new file mode 100755 index 00000000..a2e1f606 --- /dev/null +++ b/.github/scripts/verify-rpm.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# verify-rpm.sh — assert an RPM built by build-rpms.sh is actually correct. +# +# Every check below fails the script (and therefore the job) on mismatch. The +# full `rpm -qip` / `rpm -qlp` output is also printed for human review. +# +# Usage: +# verify-rpm.sh +# +# raw-version version as passed to nfpm, e.g. 0.6.4 or 0.6.4-beta.1 +# expected-arch x86_64 | aarch64 +# expected-elf-machine ELF e_machine, little-endian hex: 3e00 (x86-64), b700 (AArch64) +# +# Requires rpm, rpm2cpio and cpio (present on ubuntu-latest; the caller should +# apt-install rpm if `command -v rpm` fails). +set -euo pipefail + +if [ "$#" -ne 4 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +RPM_FILE="$1" +RAW_VERSION="$2" +EXPECT_ARCH="$3" +EXPECT_MACHINE="$4" + +EXPECT_NAME="routatic-proxy" +EXPECT_LICENSE="AGPL-3.0-only" + +# nfpm's semver version_schema rewrites the prerelease separator so the RPM +# version sorts below the matching stable release: 0.6.4-beta.1 -> 0.6.4~beta.1 +EXPECT_VERSION="$(printf '%s' "$RAW_VERSION" | tr '-' '~')" + +FAILED=0 +fail() { + echo "::error::$1" + FAILED=1 +} + +if [ ! -f "$RPM_FILE" ]; then + echo "::error::$RPM_FILE does not exist" + exit 1 +fi + +for TOOL in rpm rpm2cpio cpio; do + if ! command -v "$TOOL" >/dev/null 2>&1; then + echo "::error::required tool '$TOOL' not found on PATH" + exit 1 + fi +done + +echo "==============================================================" +echo "Verifying $(basename "$RPM_FILE")" +echo " expect name=${EXPECT_NAME} version=${EXPECT_VERSION} arch=${EXPECT_ARCH}" +echo "==============================================================" + +# ── Header metadata (printed in full, then asserted field by field) ── +echo "--- rpm -qip ---" +rpm -qip "$RPM_FILE" +echo + +read -r GOT_NAME GOT_VERSION GOT_ARCH GOT_LICENSE <}', want 'cn' (config|noreplace)" +fi + +# ── Extract the payload and inspect the real binary ── +WORKDIR="$(mktemp -d)" +# shellcheck disable=SC2064 # expand WORKDIR now, not at trap time +trap "rm -rf '$WORKDIR'" EXIT + +RPM_ABS="$(cd "$(dirname "$RPM_FILE")" && pwd)/$(basename "$RPM_FILE")" +(cd "$WORKDIR" && rpm2cpio "$RPM_ABS" | cpio -idm --quiet) + +BIN="${WORKDIR}/usr/bin/routatic-proxy" +if [ ! -f "$BIN" ]; then + fail "Extracted payload has no regular file at usr/bin/routatic-proxy" +else + if [ -x "$BIN" ]; then + echo "binary: executable ok ($(stat -c '%A' "$BIN"))" + else + fail "Packaged binary is not executable (mode $(stat -c '%A' "$BIN"))" + fi + + # Read the ELF header directly rather than parsing `file` output, whose + # wording differs between platforms. A guard that silently always passes is + # worse than no guard. + # bytes 0-3 magic 7f 45 4c 46 + # byte 4 class 02 = 64-bit + # bytes 18-19 e_machine (LE) 3e00 = x86-64, b700 = AArch64 + HEADER=$(dd if="$BIN" bs=1 count=20 2>/dev/null | od -An -tx1 | tr -d ' \n') + MAGIC="${HEADER:0:8}" + CLASS="${HEADER:8:2}" + MACHINE="${HEADER:36:4}" + + [ "$MAGIC" = "7f454c46" ] || + fail "Packaged binary is not an ELF file (magic=$MAGIC)" + [ "$CLASS" = "02" ] || + fail "Packaged binary is not 64-bit ELF (class=$CLASS)" + [ "$MACHINE" = "$EXPECT_MACHINE" ] || + fail "Packaged binary has wrong ELF machine: got $MACHINE, want $EXPECT_MACHINE" + + if [ "$MAGIC" = "7f454c46" ] && [ "$CLASS" = "02" ] && [ "$MACHINE" = "$EXPECT_MACHINE" ]; then + echo "binary: ELF64 e_machine=$MACHINE ok" + fi +fi + +if [ "$FAILED" -ne 0 ]; then + echo "::error::$(basename "$RPM_FILE") failed verification" + exit 1 +fi + +echo "$(basename "$RPM_FILE"): all checks passed" diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 9370847a..bf91354a 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -31,10 +31,85 @@ jobs: - name: Build (sanity check) run: go build -o /dev/null ./cmd/routatic-proxy - # ── Stage 2: Build & Release (macOS runner for DMG support) ────── + # ── Stage 2: Package & Verify RPMs (Linux runner) ───────────────── + # RPMs are built here rather than in the release job because rpm(8) — and so + # any real introspection of what we are about to publish — only exists on + # Linux. The finished .rpm files travel to the release job as a workflow + # artifact so every asset still goes into one atomic `gh release create` + # (this repo publishes immutable releases, which reject later uploads). + rpm: + name: Package & Verify RPMs + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: "1.25" + cache: true + + - name: Get versions + id: version + run: | + # Same derivation as the release job (fetch-depth: 0 above is required + # — the script reads tags from origin/releases) so the version baked + # into the packaged binary matches the published raw binaries exactly. + chmod +x .github/scripts/get-versions.sh + VERSIONS_JSON=$(.github/scripts/get-versions.sh) + BETA_VERSION=$(echo "$VERSIONS_JSON" | jq -r '.beta_version') + + echo "version=${BETA_VERSION#v}" >> "$GITHUB_OUTPUT" + echo "Beta tag: ${BETA_VERSION}" + + - name: Ensure rpm tooling + run: | + set -euo pipefail + # ubuntu-latest ships rpm/rpm2cpio/cpio today; install rather than + # assume, so a runner image change turns into a slower job and not a + # silently skipped verification. + MISSING="" + for TOOL in rpm rpm2cpio cpio; do + command -v "$TOOL" >/dev/null 2>&1 || MISSING="$MISSING $TOOL" + done + if [ -n "$MISSING" ]; then + echo "Installing missing tooling:$MISSING" + sudo apt-get update + sudo apt-get install -y rpm cpio + fi + rpm --version + + - name: Build RPM packages + env: + VERSION: ${{ steps.version.outputs.version }} + run: bash .github/scripts/build-rpms.sh + + - name: Verify RPM packages + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + # nfpm's semver schema rewrites 0.6.4-beta.1 -> 0.6.4~beta.1. + RPM_VERSION="$(printf '%s' "$VERSION" | tr '-' '~')" + bash .github/scripts/verify-rpm.sh \ + "dist/routatic-proxy-${RPM_VERSION}-1.x86_64.rpm" "$VERSION" x86_64 3e00 + bash .github/scripts/verify-rpm.sh \ + "dist/routatic-proxy-${RPM_VERSION}-1.aarch64.rpm" "$VERSION" aarch64 b700 + + - name: Upload RPM packages + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: rpm-packages + path: dist/*.rpm + if-no-files-found: error + retention-days: 7 + + # ── Stage 3: Build & Release (macOS runner for DMG support) ────── release: name: Build & Release - needs: validate + needs: [validate, rpm] runs-on: macos-latest outputs: beta_tag: ${{ steps.version.outputs.beta_tag }} @@ -131,10 +206,10 @@ jobs: echo "Built binaries:" ls -lh dist/ - # RPM packaging runs here rather than on a Linux runner: nfpm is pure Go - # and writes RPMs natively (no rpmbuild), and every asset must go into the - # single atomic `gh release create` call below — this repo publishes - # immutable releases, which reject post-creation asset uploads. + # Guards the raw linux-amd64/linux-arm64 binaries that this job publishes + # directly as release assets. The RPM job verifies its own copies; this + # check is independent, because a bad cross-compile here would ship to + # users via the plain downloads and the Homebrew formula. - name: Verify Linux binaries run: | set -euo pipefail @@ -183,32 +258,6 @@ jobs: check amd64 3e00 check arm64 b700 - - name: Build RPM packages - env: - VERSION: ${{ steps.version.outputs.version }} - run: | - set -euo pipefail - go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.47.0 - - # nfpm's semver schema turns 0.5.3-beta.1 into RPM version - # 0.5.3~beta.1, which sorts below the matching stable release. - # Go arch names go in; nfpm maps amd64 -> x86_64, arm64 -> aarch64. - for ARCH in amd64 arm64; do - echo "Packaging ${ARCH}..." - NFPM_VERSION="$VERSION" \ - NFPM_ARCH="$ARCH" \ - NFPM_BINARY="dist/routatic-proxy_linux-${ARCH}" \ - "$(go env GOPATH)/bin/nfpm" package \ - --config packaging/nfpm.yaml --packager rpm --target dist/ - done - - COUNT=$(find dist -name '*.rpm' | wc -l | tr -d ' ') - if [ "$COUNT" -ne 2 ]; then - echo "::error::Expected 2 RPMs, found ${COUNT}" - exit 1 - fi - ls -lh dist/*.rpm - - name: Stage Windows binaries for signing if: ${{ steps.signing.outputs.enabled == 'true' }} run: | @@ -245,6 +294,26 @@ jobs: cp signed/routatic-proxy_windows-arm64.exe dist/routatic-proxy_windows-arm64.exe rm -rf signing signed + # The RPMs were built and introspected on Linux (see the rpm job). Passing + # them through an artifact is safe: the executable bit lives inside the + # RPM payload, not on the .rpm file itself. + - name: Download RPM packages + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: rpm-packages + path: dist + + - name: Verify RPM packages arrived + run: | + set -euo pipefail + COUNT=$(find dist -maxdepth 1 -name 'routatic-proxy-*.rpm' | wc -l | tr -d ' ') + if [ "$COUNT" -ne 2 ]; then + echo "::error::Expected 2 RPMs from the rpm job artifact, found ${COUNT}" + ls -la dist + exit 1 + fi + ls -lh dist/*.rpm + - name: Generate checksums run: | cd dist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4b8080d..bcc510f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,51 @@ jobs: cache: true - name: golangci-lint - uses: golangci/golangci-lint-action@v7 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: latest - args: --timeout 5m \ No newline at end of file + version: v2.13.1 + args: --timeout 5m + # Catches RPM packaging regressions on every PR instead of only at release + # time. Same build + assertion scripts the release workflows use; nothing is + # uploaded. The version is a placeholder — packaging correctness is what is + # under test here, not version derivation. + rpm: + name: RPM Packaging + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Ensure rpm tooling + run: | + set -euo pipefail + MISSING="" + for TOOL in rpm rpm2cpio cpio; do + command -v "$TOOL" >/dev/null 2>&1 || MISSING="$MISSING $TOOL" + done + if [ -n "$MISSING" ]; then + echo "Installing missing tooling:$MISSING" + sudo apt-get update + sudo apt-get install -y rpm cpio + fi + rpm --version + + - name: Build RPM packages + env: + VERSION: 0.0.0-ci.1 + run: bash .github/scripts/build-rpms.sh + + - name: Verify RPM packages + env: + VERSION: 0.0.0-ci.1 + run: | + set -euo pipefail + RPM_VERSION="$(printf '%s' "$VERSION" | tr '-' '~')" + bash .github/scripts/verify-rpm.sh \ + "dist/routatic-proxy-${RPM_VERSION}-1.x86_64.rpm" "$VERSION" x86_64 3e00 + bash .github/scripts/verify-rpm.sh \ + "dist/routatic-proxy-${RPM_VERSION}-1.aarch64.rpm" "$VERSION" aarch64 b700 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b250a1f9..f5019cc3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,10 +35,85 @@ jobs: - name: Build (sanity check) run: go build -o /dev/null ./cmd/routatic-proxy - # ── Stage 2: Build & Release (macOS runner for DMG support) ────── + # ── Stage 2: Package & Verify RPMs (Linux runner) ───────────────── + # RPMs are built here rather than in the release job because rpm(8) — and so + # any real introspection of what we are about to publish — only exists on + # Linux. The finished .rpm files travel to the release job as a workflow + # artifact so every asset still goes into one atomic `gh release create` + # (this repo publishes immutable releases, which reject later uploads). + rpm: + name: Package & Verify RPMs + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: "1.25" + cache: true + + - name: Determine version + id: version + run: | + # Same derivation as the release job so the version baked into the + # packaged binary matches the published raw binaries exactly. + TAG="${{ github.event.inputs.version }}" + + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "Error: Version must follow semantic versioning with 'v' prefix (e.g., v1.2.3)" + exit 1 + fi + + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + echo "Packaging as $TAG" + + - name: Ensure rpm tooling + run: | + set -euo pipefail + # ubuntu-latest ships rpm/rpm2cpio/cpio today; install rather than + # assume, so a runner image change turns into a slower job and not a + # silently skipped verification. + MISSING="" + for TOOL in rpm rpm2cpio cpio; do + command -v "$TOOL" >/dev/null 2>&1 || MISSING="$MISSING $TOOL" + done + if [ -n "$MISSING" ]; then + echo "Installing missing tooling:$MISSING" + sudo apt-get update + sudo apt-get install -y rpm cpio + fi + rpm --version + + - name: Build RPM packages + env: + VERSION: ${{ steps.version.outputs.version }} + run: bash .github/scripts/build-rpms.sh + + - name: Verify RPM packages + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + # nfpm's semver schema rewrites 0.6.4-beta.1 -> 0.6.4~beta.1. + RPM_VERSION="$(printf '%s' "$VERSION" | tr '-' '~')" + bash .github/scripts/verify-rpm.sh \ + "dist/routatic-proxy-${RPM_VERSION}-1.x86_64.rpm" "$VERSION" x86_64 3e00 + bash .github/scripts/verify-rpm.sh \ + "dist/routatic-proxy-${RPM_VERSION}-1.aarch64.rpm" "$VERSION" aarch64 b700 + + - name: Upload RPM packages + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: rpm-packages + path: dist/*.rpm + if-no-files-found: error + retention-days: 7 + + # ── Stage 3: Build & Release (macOS runner for DMG support) ────── release: name: Build & Release - needs: validate + needs: [validate, rpm] runs-on: macos-latest outputs: tag: ${{ steps.version.outputs.tag }} @@ -129,10 +204,10 @@ jobs: echo "Built binaries:" ls -lh dist/ - # RPM packaging runs here rather than on a Linux runner: nfpm is pure Go - # and writes RPMs natively (no rpmbuild), and every asset must go into the - # single atomic `gh release create` call below — this repo publishes - # immutable releases, which reject post-creation asset uploads. + # Guards the raw linux-amd64/linux-arm64 binaries that this job publishes + # directly as release assets. The RPM job verifies its own copies; this + # check is independent, because a bad cross-compile here would ship to + # users via the plain downloads and the Homebrew formula. - name: Verify Linux binaries run: | set -euo pipefail @@ -181,32 +256,6 @@ jobs: check amd64 3e00 check arm64 b700 - - name: Build RPM packages - env: - VERSION: ${{ steps.version.outputs.version }} - run: | - set -euo pipefail - go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.47.0 - - # nfpm's semver schema turns 0.5.3-beta.1 into RPM version - # 0.5.3~beta.1, which sorts below the matching stable release. - # Go arch names go in; nfpm maps amd64 -> x86_64, arm64 -> aarch64. - for ARCH in amd64 arm64; do - echo "Packaging ${ARCH}..." - NFPM_VERSION="$VERSION" \ - NFPM_ARCH="$ARCH" \ - NFPM_BINARY="dist/routatic-proxy_linux-${ARCH}" \ - "$(go env GOPATH)/bin/nfpm" package \ - --config packaging/nfpm.yaml --packager rpm --target dist/ - done - - COUNT=$(find dist -name '*.rpm' | wc -l | tr -d ' ') - if [ "$COUNT" -ne 2 ]; then - echo "::error::Expected 2 RPMs, found ${COUNT}" - exit 1 - fi - ls -lh dist/*.rpm - - name: Stage Windows binaries for signing if: ${{ steps.signing.outputs.enabled == 'true' }} run: | @@ -243,6 +292,26 @@ jobs: cp signed/routatic-proxy_windows-arm64.exe dist/routatic-proxy_windows-arm64.exe rm -rf signing signed + # The RPMs were built and introspected on Linux (see the rpm job). Passing + # them through an artifact is safe: the executable bit lives inside the + # RPM payload, not on the .rpm file itself. + - name: Download RPM packages + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: rpm-packages + path: dist + + - name: Verify RPM packages arrived + run: | + set -euo pipefail + COUNT=$(find dist -maxdepth 1 -name 'routatic-proxy-*.rpm' | wc -l | tr -d ' ') + if [ "$COUNT" -ne 2 ]; then + echo "::error::Expected 2 RPMs from the rpm job artifact, found ${COUNT}" + ls -la dist + exit 1 + fi + ls -lh dist/*.rpm + - name: Generate checksums run: | cd dist diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..facf85de --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,76 @@ +# golangci-lint configuration (schema v2, golangci-lint >= 2.0). +# +# Rule of thumb for this repo: every linter enabled here must pass on a clean +# tree with zero issues. A config that fails out of the box gets ignored, and +# an ignored lint config is worse than no lint config. If you want to add a +# linter, run it first and either fix what it finds or leave it out. +# +# Formatting is deliberately NOT handled here — `make lint` owns the gofmt +# check so the fast path stays fast and the two never disagree. +version: "2" + +linters: + # standard = errcheck, govet, ineffassign, staticcheck, unused. + default: standard + enable: + - bodyclose # unclosed HTTP response bodies — we make a lot of upstream calls + - copyloopvar # leftover loop-variable copies (unneeded since Go 1.22) + - misspell # typos in comments and strings + - nolintlint # //nolint must be specific and explained + - revive # curated correctness/clarity rules, see settings below + - whitespace # stray leading/trailing newlines inside blocks + + # Considered and deliberately left out, because each one currently reports + # findings that would mean a large mechanical diff rather than a real fix: + # gosec — 25+ findings, nearly all inherent to a local CLI that writes + # config files and spawns processes (G301/G302/G304/G306/G204). + # Silencing all of them would leave gosec meaningless; run it + # ad hoc instead: golangci-lint run --enable-only gosec ./... + # errorlint — sentinel comparisons via == that predate errors.Is + # gocritic — if-else/switch rewrites, pure style + # prealloc, unparam, unconvert, usestdlibvars — a handful of micro-findings + # Revisit any of these as a standalone cleanup PR, not as a lint config change. + + settings: + revive: + # Explicit rule list. The revive default set includes exported-comment and + # unused-parameter, which together flag ~50 sites across the tree; those are + # a documentation/signature cleanup, not something to gate pushes on. + rules: + - name: constant-logical-expr + - name: datarace + - name: defer + - name: dot-imports + - name: duplicated-imports + - name: error-naming + - name: error-return + - name: error-strings + - name: errorf + - name: identical-branches + - name: increment-decrement + - name: indent-error-flow + - name: modifies-parameter + - name: modifies-value-receiver + - name: range + - name: receiver-naming + - name: superfluous-else + - name: time-equal + - name: time-naming + - name: unreachable-code + - name: useless-break + - name: var-declaration + - name: waitgroup-by-value + + exclusions: + rules: + # Tests routinely ignore errors on teardown and don't drain response + # bodies they only inspect status codes for. Conventional relaxation. + - path: _test\.go + linters: + - bodyclose + - errcheck + +issues: + # Report everything, not just the first few per linter. + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/CLAUDE.md b/CLAUDE.md index c0aa1896..04a833e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ make build # Build binary to bin/routatic-proxy (CGO disabled by default) make run # Run without building make test # Run tests with race detector make lint # gofmt check + go vet (does NOT run tests) +make lint-strict # golangci-lint run with .golangci.yml (requires golangci-lint 2.x) make clean # Remove build artifacts make install # Build and install to $GOPATH/bin make dist # Cross-compile for all platforms @@ -91,7 +92,7 @@ The "typical provider" column reflects how the shipped config wires each model; The three vision scenarios are `ScenarioVision`, `ScenarioVisionComplex`, and `ScenarioVisionLongContext` (scenarios.go). The shipped default config has no `vision*` model entries, so vision requests fall through to the ordinary scenario models unless you add them. -Note: the human-readable `Reason` strings inside `scenarios.go` still name older models (GLM-5.1, Qwen3.5 Plus, Kimi K2.6). They are log text only — the actual model comes from config, not from those strings. +The `Reason` strings in `scenarios.go` describe only *why* a scenario matched and name no model. The resolved model is appended by `ModelRouter.Route` / `RouteForStreaming` (`describeRouting`), so the routing log line always reports the model that actually came from config — e.g. `scenario=complex (complex or tool-based operation keywords in latest user message) -> resolved model glm-5.2`. A test asserts detector reasons never name a model, so they cannot drift again. **Model overrides:** two config blocks bypass scenario routing based on the requested model. `model_overrides` matches the `model` string **exactly** (best with CC-Switch, which sends a custom model string). `model_family_overrides` maps a Claude family keyword (`opus`, `sonnet`, `haiku`) via **case-insensitive substring** match, so the versioned IDs Claude Code sends natively (`claude-opus-4-20250514`) route without CC-Switch. Precedence: exact `model_overrides` → `model_family_overrides` (longest key first) → `respect_requested_model` → scenario routing. Both are wired through `ModelRouter.RouteWithOverride` / `RouteWithFamilyOverride` (`internal/router/model_router.go`) and merged with a deduplicated scenario safety-net chain in `buildModelChain` (`internal/handlers/messages.go`). @@ -217,12 +218,15 @@ Production releases include all beta features plus: Both workflows share the same stages: 1. **validate** — Run `go vet`, `go test -race`, and build sanity check on ubuntu-latest -2. **release** — Build cross-platform binaries and macOS DMG on macos-latest -3. **docker** — Publish multi-arch Docker images on ubuntu-latest +2. **rpm** — Build and verify the Fedora RPMs on ubuntu-latest, then pass them to `release` as the `rpm-packages` artifact (`.github/scripts/build-rpms.sh` and `verify-rpm.sh`) +3. **release** — Build cross-platform binaries and macOS DMG on macos-latest, and publish every asset — binaries, DMG, RPMs, checksums — through one atomic `gh release create` +4. **docker** — Publish multi-arch Docker images on ubuntu-latest Production adds: -4. **homebrew** — Update the homebrew-tap formula -5. **scoop** — Update the scoop-bucket manifest +5. **homebrew** — Update the homebrew-tap formula +6. **scoop** — Update the scoop-bucket manifest + +The RPMs are packaged in their own Linux job rather than in `release` for two reasons: `rpm`/`rpm2cpio` are unavailable on the macOS runner, so verification has to happen on Linux; and this repo publishes **immutable releases**, so assets cannot be added after `gh release create` — everything must be present for that single call. ## Skill routing diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 392787b9..ecdcc394 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -166,7 +166,7 @@ When OpenCode Go returns `GoUsageLimitError`, remaining Go models are skipped fo ## Providers -routatic-proxy supports three providers for upstream API calls: +routatic-proxy supports four providers for upstream API calls: ### OpenCode Go (`opencode-go`) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5067d521..5f238a00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,13 +8,18 @@ Optional but recommended: -- [golangci-lint](https://golangci-lint.run/usage/install/) — used by the repo's - `pre-push` hook (`scripts/git-hooks/pre-push`, installed via - `scripts/install-hooks.sh`). The hook runs `golangci-lint run --timeout 5m` - when the binary is on your `PATH` and skips the step with a warning when it - isn't. There is no `.golangci.yml` in the repo, so its default linter set - applies. CI and `make lint` do **not** run golangci-lint — `make lint` is a - `gofmt` check plus `go vet`. +- [golangci-lint](https://golangci-lint.run/usage/install/) 2.x — configured by + `.golangci.yml` (schema v2) and run three ways: by CI on every PR, by + `make lint-strict`, and by the repo's `pre-push` hook + (`scripts/git-hooks/pre-push`, installed via `scripts/install-hooks.sh`). All + three run `golangci-lint run --timeout 5m` and pick up the same config. The + hook skips the step with a warning when the binary isn't on your `PATH`; CI + does not. `make lint` stays the fast check — `gofmt` plus `go vet`, no + golangci-lint. + + The config is expected to pass with zero issues on a clean tree. See the + comments in `.golangci.yml` for which linters are enabled and which were + deliberately left out. ## Getting Started @@ -91,6 +96,7 @@ This repository uses git hooks to ensure code quality. Install them once after c The pre-push hook runs these checks before allowing a push: - **Code formatting** (`gofmt`) — ensures consistent formatting - **Linting** (`go vet`) — catches common errors +- **Linting** (`golangci-lint`, using `.golangci.yml`) — skipped if not installed - **Tests** (`make test`) — runs all tests with race detector - **Build** (`make build`) — verifies the project compiles @@ -124,6 +130,12 @@ make test # Run go vet make vet +# Fast checks (gofmt + go vet) +make lint + +# Full lint pass (golangci-lint with .golangci.yml) +make lint-strict + # Clean build artifacts make clean diff --git a/MODELS.md b/MODELS.md index 5953fa68..b4cec222 100644 --- a/MODELS.md +++ b/MODELS.md @@ -349,7 +349,7 @@ The catalog system extracts the provider from the key prefix: | -------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------ | | MiniMax M2.5, MiniMax M2.7, MiniMax M3, GLM-5, GLM-5.1, GLM-5.2, Kimi K2.5, Kimi K2.6, Kimi K2.7 Code, Kimi K3, DeepSeek V4 Pro, DeepSeek V4 Flash, DeepSeek V4 Flash Free, Grok Build 0.1, Big Pickle, MiMo-V2.5 Free, North Mini Code Free, Nemotron 3 Ultra Free | `https://opencode.ai/zen/v1/chat/completions` | OpenAI-compatible | | **Claude models** (claude-fable-5, claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-opus-4-5, claude-opus-4-1, claude-sonnet-4-6, claude-sonnet-4-5, claude-sonnet-4, claude-haiku-4-5, claude-3-5-haiku), **Qwen models** (qwen3.5-plus, qwen3.6-plus, qwen3.7-plus, qwen3.7-max) | `https://opencode.ai/zen/v1/messages` | **Anthropic-compatible** | -| **GPT models** (gpt-5.5, gpt-5.5-pro, gpt-5.4, gpt-5.4-pro, gpt-5.4-mini, gpt-5.4-nano, gpt-5.3-codex, gpt-5.3-codex-spark, gpt-5.2, gpt-5.2-codex, gpt-5.1, gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5, gpt-5-codex, gpt-5-nano) | `https://opencode.ai/zen/v1/responses` | **OpenAI Responses** | +| **GPT models** (gpt-5.5, gpt-5.5-pro, gpt-5.5-mini, gpt-5.5-nano, gpt-5.4, gpt-5.4-pro, gpt-5.4-mini, gpt-5.4-nano, gpt-5.3-codex, gpt-5.3-codex-spark, gpt-5.2, gpt-5.2-codex, gpt-5.1, gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5, gpt-5-codex, gpt-5-nano) | `https://opencode.ai/zen/v1/responses` | **OpenAI Responses** | | **Gemini models** (gemini-3.5-flash, gemini-3.1-pro, gemini-3-flash) | `https://opencode.ai/zen/v1/models/{id}` | **Google Gemini** | **Why this matters:** On the Go provider, MiniMax and Qwen models use Anthropic format natively. On Zen, only Claude and Qwen use the Anthropic endpoint — MiniMax uses chat completions. routatic-proxy handles all routing automatically. @@ -376,7 +376,7 @@ To use Zen models, set `"provider": "opencode-zen"` in your model config: All OpenCode Go models are also available on Zen. Zen additionally offers: - **Claude Models (Anthropic endpoint):** claude-fable-5, claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-opus-4-5, claude-opus-4-1, claude-sonnet-4-6, claude-sonnet-4-5, claude-sonnet-4, claude-haiku-4-5, claude-3-5-haiku -- **GPT Models (Responses endpoint):** gpt-5.5, gpt-5.5-pro, gpt-5.4, gpt-5.4-pro, gpt-5.4-mini, gpt-5.4-nano, gpt-5.3-codex, gpt-5.3-codex-spark, gpt-5.2, gpt-5.2-codex, gpt-5.1, gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5, gpt-5-codex, gpt-5-nano +- **GPT Models (Responses endpoint):** gpt-5.5, gpt-5.5-pro, gpt-5.5-mini, gpt-5.5-nano, gpt-5.4, gpt-5.4-pro, gpt-5.4-mini, gpt-5.4-nano, gpt-5.3-codex, gpt-5.3-codex-spark, gpt-5.2, gpt-5.2-codex, gpt-5.1, gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5, gpt-5-codex, gpt-5-nano - **Gemini Models (Gemini endpoint):** gemini-3.5-flash, gemini-3.1-pro, gemini-3-flash - **Free Tier (chat completions):** deepseek-v4-flash-free, big-pickle, mimo-v2.5-free, north-mini-code-free, nemotron-3-ultra-free @@ -463,9 +463,9 @@ To route DeepSeek V4 Pro through Zen (free tier) instead of Go (paid), add a `mo "max_tokens": 4096 }, "long_context": { - // Large files only - "model_id": "minimax-m2.5", - "context_threshold": 80000 + // Large files only — needs a 1M-context model + "model_id": "minimax-m3", + "context_threshold": 100000 }, "think": { // Reasoning tasks @@ -489,8 +489,8 @@ To route DeepSeek V4 Pro through Zen (free tier) instead of Go (paid), add a `mo ### Decision Tree ``` -Is context > 80K tokens? -├── YES → Use MiniMax M2.5 (1M context, 6,300 req/$12) +Is context > 100K tokens? (default threshold, configurable via context_threshold) +├── YES → Use MiniMax M3 (1M context, 3,200 req/$12) │ Is it a complex task (architecture, refactoring, tool operations)? ├── YES → Use GLM-5.1 (880 req/$12) @@ -512,8 +512,9 @@ Default → Use Kimi K2.6 (1,850 req/$12, ★★★★★) or Qwen3.6 Plus (3,30 - **Model ID:** `qwen3.5-plus` - **Cost:** **10,200 requests per $12** (best value!) -- **Context:** ~128K tokens +- **Context:** **~1M tokens** - **Quality:** ★★☆☆☆ (adequate for simple tasks) +- **Modalities:** Text and image input - **Best For:** - File reading operations - Directory listing @@ -523,32 +524,35 @@ Default → Use Kimi K2.6 (1,850 req/$12, ★★★★★) or Qwen3.6 Plus (3,30 - Background tasks - **When to Use:** When you need to do lots of operations cheaply -#### MiniMax M2.5 — Long Context on a Budget +#### MiniMax M2.5 — Cheapest 200K-Class Model - **Model ID:** `minimax-m2.5` - **Endpoint:** **Anthropic-compatible** (`/v1/messages` on Go), **OpenAI-compatible** (`/chat/completions` on Zen) - **Cost:** **6,300 requests per $12** -- **Context:** **~1M tokens** (1 million!) +- **Context:** ~200K tokens +- **Max Output:** 4K tokens - **Quality:** ★★☆☆☆ (acceptable) - **Speed:** Fast - **Best For:** - - Very large files - - Long conversations + - Large files that still fit inside 200K + - Long conversations on a tight budget - Multi-file context -- **When to Use:** When you need 1M context but want to minimize cost +- **When to Use:** When 200K of context is enough and cost is the priority. For genuinely long context (>100K, up to 1M) use MiniMax M3 instead. - **Note:** Uses Anthropic endpoint on Go but chat completions on Zen - routatic-proxy handles this automatically #### MiniMax M3 — Latest MiniMax, 1M Context - **Model ID:** `minimax-m3` - **Endpoint:** **Anthropic-compatible** (`/v1/messages` on Go), **OpenAI-compatible** (`/chat/completions` on Zen) +- **Cost:** **3,200 requests per $12** - **Context:** **~1M tokens** +- **Max Output:** 128K tokens - **Quality:** ★★★☆☆ - **Best For:** - - Long-context tasks requiring better quality than M2.5 + - Long-context tasks (the recommended `long_context` model) - Large codebase analysis - Document processing -- **When to Use:** When you need 1M context and want better quality than M2.5 +- **When to Use:** Whenever the request exceeds the long-context threshold — M2.5 tops out at 200K, M3 goes to 1M ### Balanced Models (Quality + Cost) @@ -626,8 +630,9 @@ Default → Use Kimi K2.6 (1,850 req/$12, ★★★★★) or Qwen3.6 Plus (3,30 - **Model ID:** `qwen3.6-plus` - **Endpoint:** **Anthropic-compatible** (`/v1/messages` — Go), **Anthropic-compatible** (`/v1/messages` — Zen) - **Cost:** **3,300 requests per $12** (3.8x more than GLM-5.1!) -- **Context:** ~128K tokens +- **Context:** **~1M tokens** - **Quality:** ★★★☆☆ (good enough for most tasks) +- **Modalities:** Text and image input - **Speed:** Fast - **Best For:** - General coding (default choice) @@ -636,29 +641,7 @@ Default → Use Kimi K2.6 (1,850 req/$12, ★★★★★) or Qwen3.6 Plus (3,30 - Refactoring - **When to Use:** Default for cost-conscious users -#### Qwen3.7 Plus — Upgraded General Coding - -- **Model ID:** `qwen3.7-plus` -- **Endpoint:** **Anthropic-compatible** (`/v1/messages`) -- **Context:** ~128K tokens -- **Quality:** ★★★★☆ -- **Speed:** Fast -- **Best For:** - - General coding with better quality than Qwen3.6 - - Feature implementation - - Bug fixes -- **When to Use:** When you want better quality than Qwen3.6 at similar speed - -#### Qwen3.7 Max — Maximum Quality Qwen - -- **Model ID:** `qwen3.7-max` -- **Endpoint:** **Anthropic-compatible** (`/v1/messages`) -- **Context:** ~128K tokens -- **Quality:** ★★★★☆ -- **Best For:** - - Complex coding tasks - - When Qwen3.7 Plus isn't enough -- **When to Use:** When you need Qwen's best quality +**Qwen3.7 Plus / Max** — see the Premium Models section below. #### Kimi K2.6 — Best Quality at Balanced Cost @@ -666,6 +649,7 @@ Default → Use Kimi K2.6 (1,850 req/$12, ★★★★★) or Qwen3.6 Plus (3,30 - **Cost:** **~1,850 requests per $12** - **Context:** ~256K tokens (successor to K2.5 with improvements) - **Quality:** ★★★★★ (excellent — successor improvements) +- **Modalities:** Text and image input - **Speed:** Fast - **Best For:** - Complex coding tasks @@ -680,6 +664,7 @@ Default → Use Kimi K2.6 (1,850 req/$12, ★★★★★) or Qwen3.6 Plus (3,30 - **Cost:** **1,850 requests per $12** - **Context:** ~256K tokens (2x most others) - **Quality:** ★★★★☆ (excellent) +- **Modalities:** Text and image input - **Speed:** Fast - **Best For:** - Complex coding tasks @@ -755,6 +740,7 @@ Default → Use Kimi K2.6 (1,850 req/$12, ★★★★★) or Qwen3.6 Plus (3,30 - **Context:** ~256K tokens - **Quality:** ★★★★★ (excellent for code tasks) - **Max Output:** 32K tokens (highest available!) +- **Modalities:** Text and image input - **Speed:** Fast - **Best For:** - Large code generation tasks @@ -768,8 +754,9 @@ Default → Use Kimi K2.6 (1,850 req/$12, ★★★★★) or Qwen3.6 Plus (3,30 - **Model ID:** `qwen3.7-plus` - **Endpoint:** **Anthropic-compatible** (`/v1/messages`) - **Cost:** **4,300 requests per $12** (better value than Qwen3.6!) -- **Context:** ~128K tokens +- **Context:** **~1M tokens** - **Quality:** ★★★★☆ +- **Modalities:** Text and image input - **Speed:** Fast - **Best For:** - General coding with better quality than Qwen3.6 @@ -782,8 +769,9 @@ Default → Use Kimi K2.6 (1,850 req/$12, ★★★★★) or Qwen3.6 Plus (3,30 - **Model ID:** `qwen3.7-max` - **Endpoint:** **Anthropic-compatible** (`/v1/messages`) - **Cost:** **950 requests per $12** -- **Context:** ~128K tokens +- **Context:** **~1M tokens** - **Quality:** ★★★★☆ +- **Modalities:** Text and image input - **Best For:** - Complex coding tasks - When Qwen3.7 Plus isn't enough @@ -840,7 +828,13 @@ Critical review → GLM-5.1 (rarely) { "model_id": "qwen3.6-plus" }, { "model_id": "minimax-m2.5" } ], - "long_context": [{ "model_id": "minimax-m2.7" }], + "long_context": [ + { "provider": "opencode-go", "model_id": "qwen3.7-plus" }, + { "provider": "opencode-go", "model_id": "qwen3.7-max" }, + { "provider": "opencode-zen", "model_id": "nemotron-3-ultra-free" }, + { "provider": "opencode-zen", "model_id": "mimo-v2.5-free" }, + { "provider": "opencode-zen", "model_id": "deepseek-v4-flash-free" } + ], "default": [{ "model_id": "mimo-v2.5-pro" }, { "model_id": "qwen3.6-plus" }], "think": [{ "model_id": "kimi-k2.6" }], "complex": [{ "model_id": "glm-5" }], @@ -858,7 +852,7 @@ Critical review → GLM-5.1 (rarely) | Read file, ls, grep | Qwen3.5 Plus | 10,200 | Qwen3.6 Plus | | General coding | Qwen3.7 Plus | 4,300 | Qwen3.6 Plus | | Complex features | Kimi K2.6 | 1,850 | MiMo-V2.5-Pro | -| Long context (>80K) | MiniMax M2.5 | 6,300 | MiniMax M2.7 | +| Long context (>100K) | MiniMax M3 | 3,200 | Qwen3.7 Plus | | Reasoning/planning | GLM-5 | 1,150 | Kimi K2.6 | | Critical architecture | GLM-5.2 | 880 | GLM-5.1 | | Code specialist | Kimi K2.7 Code | 1,350 | Kimi K2.6 | @@ -869,12 +863,12 @@ Critical review → GLM-5.1 (rarely) 1. **Use Qwen3.6 Plus as default** — 3,300 req/$12 is plenty for most tasks 2. **Reserve GLM-5.1 for critical tasks only** — 880 req/$12 drains budget fast 3. **Use Qwen3.5 Plus for simple operations** — 10,200 req/$12 is unbeatable -4. **MiniMax M2.5 for long context** — 6,300 req/$12 with 1M context is amazing value +4. **MiniMax M3 for long context** — 3,200 req/$12 with a 1M window; MiniMax M2.5 stays the budget pick at 6,300 req/$12 as long as you fit inside its 200K window 5. **Use Zen free-tier models** for non-critical tasks — Nemotron 3 Ultra Free, MiMo V2.5 Free, DeepSeek V4 Flash Free, Big Pickle, and others cost $0 while their promotions remain active 6. **Monitor your usage** in the [OpenCode console](https://opencode.ai/auth) ## See Also - [OpenCode Go Documentation](https://opencode.ai/docs/go/) -- [routatic-proxy Configuration](../configs/config.example.json) -- [README.md](../README.md) for setup instructions +- [routatic-proxy Configuration](configs/config.example.json) +- [README.md](README.md) for setup instructions diff --git a/Makefile b/Makefile index 7c675323..fd61d67e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-ui run test clean install dist rpm lint vet docker-up docker-stop +.PHONY: build build-ui run test clean install dist rpm lint lint-strict vet docker-up docker-stop # Build variables VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") @@ -42,6 +42,19 @@ lint: CGO_ENABLED=0 go vet ./... @echo "Lint checks passed!" +# Full lint pass with .golangci.yml — same command the pre-push hook runs. +GOLANGCI_LINT_VERSION = v2.13.1 + +lint-strict: + @command -v golangci-lint >/dev/null || { \ + echo "golangci-lint not found. Install with:"; \ + echo " go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \ + echo " (or: brew install golangci-lint / dnf install golangci-lint)"; \ + exit 1; \ + } + @echo "Running golangci-lint..." + golangci-lint run --timeout 5m ./... + clean: rm -rf bin/ dist/ diff --git a/README.md b/README.md index a8be13cb..c70a20e5 100644 --- a/README.md +++ b/README.md @@ -124,10 +124,9 @@ routatic-proxy --version Show version | Document | Description | |----------|-------------| -| [docs/models.md](docs/models.md) | Model reference across all providers | +| [MODELS.md](MODELS.md) | Model reference across all providers — capabilities, costs, endpoints, routing recommendations | | [docs/openrouter.md](docs/openrouter.md) | OpenRouter provider setup and configuration | | [CONFIGURATION.md](CONFIGURATION.md) | Config file reference, env vars, model routing, fallback chains | -| [MODELS.md](MODELS.md) | Complete model capabilities, costs, and routing recommendations | | [INSTALLATION.md](INSTALLATION.md) | Homebrew, Scoop, build from source, Docker | | [CONTRIBUTING.md](CONTRIBUTING.md) | Development setup, architecture | | [TROUBLESHOOTING.md](TROUBLESHOOTING.md) | Common issues and debug mode | diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index 067eab65..938cda50 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -85,11 +85,16 @@ Each beta release includes: - `routatic-proxy-{version}-1.aarch64.rpm` - Fedora/RHEL package (ARM64) - `checksums.txt` - SHA256 checksums for all binaries and RPMs -The RPMs are built with `nfpm` inside the `release` job and included in the same -atomic `gh release create` call as everything else, because immutable releases -reject assets added after the release exists. See `packaging/nfpm.yaml` and -`make rpm`. Beta RPMs use an RPM-native prerelease version (`0.6.4~beta.1-1`) -so they sort below the eventual stable package. +The RPMs are built with `nfpm` and verified in a dedicated `rpm` job on +ubuntu-latest (`.github/scripts/build-rpms.sh`, then `verify-rpm.sh` asserts +metadata, payload paths, the `noreplace` config flag, and the packaged binary's +ELF architecture). They are handed to the `release` job as the `rpm-packages` +artifact and included in the same atomic `gh release create` call as everything +else, because immutable releases reject assets added after the release exists. +Verification runs on Linux because `rpm`/`rpm2cpio` do not exist on the macOS +runner. See `packaging/nfpm.yaml` and `make rpm` for local builds. Beta RPMs use +an RPM-native tilde version (`0.6.4~beta.1-1`) so they sort below the eventual +stable package. ### Docker Tags for Beta diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 7f34a454..461cadd8 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -28,6 +28,16 @@ All models in the fallback chain returned errors. Check: 2. You haven't exceeded your [usage limits](https://opencode.ai/auth) 3. The OpenCode Go service is reachable: `curl -H "Authorization: Bearer $ROUTATIC_PROXY_API_KEY" https://opencode.ai/zen/go/v1/models` +## Invalid API Key + +If requests are rejected before any model is tried: + +1. Verify the key in the [OpenCode console](https://opencode.ai/auth) +2. Check the key is actually set — either `api_key` in the config file or the `ROUTATIC_PROXY_API_KEY` environment variable +3. Run `routatic-proxy validate` to confirm the config loads and the key is picked up + +Provider-specific keys override the global one, so also check `ROUTATIC_PROXY_OPENCODE_GO_API_KEY` and `ROUTATIC_PROXY_OPENCODE_ZEN_API_KEY` if you've set them. + ## Connection Refused Make sure the proxy is running: @@ -42,6 +52,12 @@ And Claude Code is pointing to the right address: echo $ANTHROPIC_BASE_URL # Should be http://127.0.0.1:3456 ``` +If the proxy starts but Claude Code still can't reach it: + +1. Confirm the port matches your config — the default is 3456 +2. Check your firewall isn't blocking loopback connections +3. Run `routatic-proxy check`, which compares `ANTHROPIC_BASE_URL` against the configured host and port and flags conflicting Claude Code settings in `~/.claude/settings.json` and `~/.claude.json` + ## Streaming Not Working The proxy transforms OpenAI SSE to Anthropic SSE in real-time. If streaming appears broken: @@ -50,6 +66,20 @@ The proxy transforms OpenAI SSE to Anthropic SSE in real-time. If streaming appe 2. Check that no proxy or firewall is buffering the connection 3. Try a non-streaming request first to verify the model works +## Slow Model Responses + +1. Check which model actually handled the request — some are far slower than others. Set the log level to `debug` to see the selected model +2. Make sure the `fast` scenario is configured with low-latency models. Streaming requests fall back to `fast` when the detected scenario has no model configured +3. Rule out plain network latency to the upstream + +## Inaccurate Token Counts + +The proxy counts tokens with tiktoken's `cl100k_base` encoding. If the numbers look off: + +1. It's an estimate, not an exact count — the proxy adds a fixed per-message overhead on top of the encoded text +2. The upstream models don't all use `cl100k_base`, so their own accounting will differ +3. Long-context detection is driven by this estimate, so a request near the threshold (100K tokens by default) may route differently than you'd expect + ## Debug Mode For maximum logging, run with debug level: @@ -64,3 +94,11 @@ This logs: - Transformed request sent to upstream (OpenCode Go/Zen) - Upstream response received - SSE stream events during streaming + +## Getting Help + +If none of the above resolves it: + +1. Search the [GitHub issues](https://github.com/routatic/proxy/issues) +2. Ask on [Discord](https://discord.gg/pUrfwfTFxM) +3. Attach debug logs when you open a new issue diff --git a/docs/howto-debug-routing.md b/docs/howto-debug-routing.md index 9c626652..bc76d4f5 100644 --- a/docs/howto-debug-routing.md +++ b/docs/howto-debug-routing.md @@ -28,13 +28,20 @@ Debug logs show: ## Check Scenario Detection -The log line `INFO routing request` shows the selected scenario and model: +The log line `INFO routing request` shows the selected scenario, the model, and a `reason` explaining both: ``` -INFO routing request scenario=complex model=glm-5.1 provider=opencode-go tokens=1500 +INFO routing request scenario=complex model=glm-5.2 provider=opencode-go tokens=1500 reason="scenario=complex (complex or tool-based operation keywords in latest user message) -> resolved model glm-5.2" ``` -If the scenario is wrong, check the keyword patterns in `internal/router/scenarios.go`. +Read `reason` as two halves: + +- Before the `->`: **why** this scenario matched (which threshold was crossed, which keyword pattern fired, or which override key hit). It never names a model, because scenario detection runs before a model is resolved. +- After the `->`: the model actually resolved for that scenario, read from your config or the catalog. It always matches the `model=` field, so it cannot go stale. + +The reason also notes when resolution did something non-obvious, e.g. `cheapest catalog model` (cost-based routing picked from the catalog) or `scenario not configured so using "default" model`. + +If the scenario is wrong, check the keyword patterns in `internal/router/scenarios.go`. If the scenario is right but the model is not what you expected, the model for that scenario key is wrong in your config. ## Check Circuit Breakers diff --git a/docs/models.md b/docs/models.md index ade620d2..010ea132 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,72 +1,22 @@ # Supported Models -Complete model reference for routatic-proxy including OpenCode Go, Zen, OpenRouter, and deprecated models. - ---- - -## OpenCode Go Models - -Context and max-output values come from the built-in registry (`modelMetadata` in `internal/config/model_registry.go`). - -| Model | Context | Max output | Vision | Best For | -| ------------------------- | ------------ | ---------- | ------ | ----------------------------------------------- | -| **DeepSeek V4 Pro** | ~1M tokens | 8192 | no | Default + complex scenarios in the shipped config | -| **DeepSeek V4 Flash** | ~1M tokens | 4096 | no | Background / fast scenarios, cheap and quick | -| **GLM-5.2** | ~200K tokens | 8192 | no | Critical architecture, production code review | -| **GLM-5.1** | ~200K tokens | 8192 | no | Complex patterns, tool-heavy operations | -| **Kimi K3** | ~1M tokens | 131072 | yes | Latest Kimi, code + agentic, huge max output | -| **Kimi K2.7 Code** | ~256K tokens | 32768 | yes | Large code generation | -| **Kimi K2.6** | ~256K tokens | 8192 | yes | General purpose, common fallback | -| **Kimi K2.5** | ~256K tokens | 8192 | yes | Previous-generation Kimi fallback | -| **MiniMax M3** | ~1M tokens | 128000 | no | Long-context scenario, very large output budget | -| **MiniMax M2.7** | ~200K tokens | 8192 | no | Previous MiniMax generation | -| **MiniMax M2.5** | ~200K tokens | 4096 | no | Older MiniMax generation | -| **MiMo V2.5 Pro** | ~1M tokens | 16384 | no | Step-by-step reasoning, larger output | -| **MiMo V2.5** | ~1M tokens | 8192 | no | Step-by-step reasoning | -| **MiMo V2 Omni** | ~1M tokens | 8192 | yes | Multimodal MiMo | -| **Qwen3.7 Max** | ~1M tokens | 8192 | yes | Complex coding, Qwen's best quality | -| **Qwen3.7 Plus** | ~1M tokens | 8192 | yes | General coding, better quality than Qwen3.6 | -| **Qwen3.6 Plus** | ~1M tokens | 8192 | yes | Streaming fallback | -| **Qwen3.5 Plus** | ~1M tokens | 8192 | yes | Simple read-only ops | - -All registry models support tool use. The registry is provider-agnostic — the free variants (`deepseek-v4-flash-free`, `mimo-v2.5-free`) are wired to Zen in the shipped config. - -See [MODELS.md](../MODELS.md) for the complete model list including costs and routing recommendations. - ---- - -## OpenCode Zen Models - -Zen provides pay-as-you-go access to additional models: - -- **Claude Models**: Claude Fable 5, Claude Opus 4.8/4.6/4.5/4.1, Claude Sonnet 4 -- **Gemini Models**: Gemini 3.5 Flash, Gemini 3.1 Pro, Gemini 3 Flash -- **GPT Models**: GPT 5.5, GPT 5.4, GPT 5.3 Codex, and more -- **Free Tier**: Nemotron 3 Ultra Free, MiMo V2.5 Free, DeepSeek V4 Flash Free, and others - -See [MODELS.md](../MODELS.md#opencodes-zen) for the full Zen model list. - ---- - -## OpenRouter Models - -OpenRouter provides unified access to 100+ models from multiple providers through a single API endpoint. - -Model IDs, context windows, and pricing change often, so this page does not keep its own copy. See [openrouter.md](./openrouter.md) ("Model Examples") for the model-key list plus complete OpenRouter setup and configuration, and [openrouter.ai/models](https://openrouter.ai/models) for authoritative live pricing and context limits. - ---- - -## Deprecated Models - -The following models are deprecated and will be removed: - -| Model | Deprecation Date | Replacement | -|-------|------------------|-------------| -| GPT 5.2/5.1/5 Codex variants | July 23, 2026 | GPT 5.3 Codex | -| Claude Sonnet 4 | June 15, 2026 | Claude Sonnet 4.5/4.6 | -| GLM 5 | May 14, 2026 | GLM 5.1/5.2 | -| MiniMax M2.1 | March 15, 2026 | MiniMax M2.5/M2.7/M3 | -| Gemini 3 Pro | March 9, 2026 | Gemini 3.1 Pro | -| Kimi K2/K2 Thinking | March 6, 2026 | Kimi K2.5/K2.6/K2.7 Code | - -See [MODELS.md](../MODELS.md#deprecated-zen-models) for the complete deprecation schedule. +The full model reference lives in **[MODELS.md](../MODELS.md)** at the repository +root: every model with its context window, max output tokens, vision and tool +support, endpoint, pricing, and the scenario each one is suited to. + +This file used to carry a second, shorter copy of those tables. Two copies meant +two places to drift, and they did — a correctness pass once fixed the capability +numbers here while leaving the same errors in `MODELS.md`. There is now one +source of truth for prose, and one for data: + +| Looking for | Go to | +|-------------|-------| +| Model capabilities, pricing, endpoints, recommendations | [MODELS.md](../MODELS.md) | +| Chinese translation | [docs/zh/MODELS.md](zh/MODELS.md) | +| The values the proxy actually enforces at runtime | `modelMetadata` in `internal/config/model_registry.go` | +| Which endpoint a model is routed to | `internal/models/classifier.go` | +| Adding a new model | [howto-add-model.md](howto-add-model.md) | +| OpenRouter specifics | [openrouter.md](openrouter.md) | + +`modelMetadata` is authoritative: when a doc and the registry disagree, the +registry is right and the doc is a bug. diff --git a/docs/zh/CONFIGURATION.md b/docs/zh/CONFIGURATION.md index f410fe94..d8ec14a2 100644 --- a/docs/zh/CONFIGURATION.md +++ b/docs/zh/CONFIGURATION.md @@ -2,8 +2,6 @@ [English](../../CONFIGURATION.md) | **中文** -> **注意:** 本翻译可能落后于英文原版。如有疑问,请以 [CONFIGURATION.md](../../CONFIGURATION.md)(英文)为准。 - ## 配置文件 位置:`~/.config/routatic-proxy/config.json` @@ -20,6 +18,10 @@ "host": "127.0.0.1", "port": 3456, "hot_reload": false, + "anthropic_first": { + "enabled": false, + "base_url": "https://api.anthropic.com" + }, "enable_cost_based_routing": false, "cost_routing": { @@ -34,38 +36,42 @@ "models": { "default": { "provider": "opencode-go", - "model_id": "kimi-k2.6", + "model_id": "deepseek-v4-pro", "temperature": 0.7, - "max_tokens": 4096 + "max_tokens": 8192, + "reasoning_effort": "max", + "thinking": { "type": "enabled" } }, "background": { "provider": "opencode-go", - "model_id": "qwen3.5-plus", + "model_id": "deepseek-v4-flash", "temperature": 0.5, "max_tokens": 2048 }, "think": { "provider": "opencode-go", - "model_id": "glm-5.1", + "model_id": "glm-5.2", "temperature": 0.7, "max_tokens": 8192 }, "complex": { "provider": "opencode-go", - "model_id": "glm-5.1", + "model_id": "deepseek-v4-pro", "temperature": 0.7, - "max_tokens": 4096 + "max_tokens": 8192, + "reasoning_effort": "max", + "thinking": { "type": "enabled" } }, "long_context": { "provider": "opencode-go", - "model_id": "minimax-m2.7", + "model_id": "minimax-m3", "temperature": 0.7, "max_tokens": 16384, "context_threshold": 80000 }, "fast": { "provider": "opencode-go", - "model_id": "qwen3.6-plus", + "model_id": "deepseek-v4-flash", "temperature": 0.7, "max_tokens": 4096 } @@ -73,13 +79,16 @@ "fallbacks": { "default": [ - { "provider": "opencode-go", "model_id": "glm-5" }, - { "provider": "opencode-go", "model_id": "qwen3.6-plus" } + { "provider": "opencode-go", "model_id": "qwen3.7-plus" }, + { "provider": "opencode-go", "model_id": "qwen3.7-max" }, + { "provider": "opencode-zen", "model_id": "nemotron-3-ultra-free" }, + { "provider": "opencode-zen", "model_id": "mimo-v2.5-free" }, + { "provider": "opencode-zen", "model_id": "deepseek-v4-flash-free" } ], - "think": [{ "provider": "opencode-go", "model_id": "glm-5" }], - "complex": [{ "provider": "opencode-go", "model_id": "glm-5" }], - "long_context": [{ "provider": "opencode-go", "model_id": "minimax-m2.5" }], - "fast": [{ "provider": "opencode-go", "model_id": "qwen3.5-plus" }] + "think": [{ "provider": "opencode-go", "model_id": "qwen3.7-plus" }], + "complex": [{ "provider": "opencode-go", "model_id": "qwen3.7-plus" }], + "long_context": [{ "provider": "opencode-go", "model_id": "qwen3.7-plus" }], + "fast": [{ "provider": "opencode-go", "model_id": "qwen3.7-plus" }] }, "model_overrides": { @@ -204,6 +213,218 @@ routatic-proxy 支持三个提供商进行上游 API 调用: 对于需要原始 Anthropic Messages 格式的模型(如 Bedrock 上的 Claude),设置 `wire_format: "anthropic"`。需要配置 `anthropic_base_url`。 +### OpenRouter (`openrouter`) + +- 统一 API,可访问来自多个提供商(OpenAI、Anthropic、Google、Meta、Mistral 等)的 200+ 模型 +- 使用 OpenAI Chat Completions API 格式 +- 按使用量付费,费率有竞争力 +- 在模型配置中设置 `"provider": "openrouter"` 使用 OpenRouter + +#### 配置结构 + +```json +{ + "openrouter": { + "name": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "${OPENROUTER_API_KEY}", + "api_keys": ["${OPENROUTER_KEY_1}", "${OPENROUTER_KEY_2}"], + "enabled": true, + "timeout_ms": 300000, + "stream_timeout_ms": 60000 + } +} +``` + +| 字段 | 类型 | 必需 | 描述 | +|------|------|------|------| +| `name` | `string` | 否 | 提供商显示名称(默认为 "openrouter") | +| `base_url` | `string` | 否 | API 端点基础 URL。默认值:`https://openrouter.ai/api/v1` | +| `api_key` | `string` | 是* | 用于认证的单个 API key。若未设置 `api_keys` 则必需 | +| `api_keys` | `string[]` | 是* | 用于轮询轮换的多个 API key。若未设置 `api_key` 则必需 | +| `enabled` | `bool` | 否 | 此提供商是否启用。默认值:`true` | +| `timeout_ms` | `int` | 否 | 请求超时(毫秒)。默认值:`300000`(5 分钟) | +| `stream_timeout_ms` | `int` | 否 | 流式传输期间的分块超时。默认值:`60000`(1 分钟) | + +*`api_key` 和 `api_keys` 中至少要配置一个。 + +#### 环境变量覆盖 + +| 变量 | 描述 | 优先级 | +|------|------|--------| +| `ROUTATIC_PROXY_OPENROUTER_API_KEY` | 单个 API key 覆盖 | 最高 | +| `ROUTATIC_PROXY_OPENROUTER_API_KEYS` | 用于轮询的逗号分隔密钥 | 最高 | +| `ROUTATIC_PROXY_OPENROUTER_BASE_URL` | 自定义 base URL 覆盖 | 最高 | + +环境变量优先于配置文件值。配置值支持 `${VAR}` 插值。 + +优先级顺序:`*_API_KEYS` → `*_API_KEY` → 配置文件 `api_keys` → 配置文件 `api_key` + +#### 配置示例 + +**单密钥设置:** + +```json +{ + "openrouter": { + "api_key": "sk-or-v1-xxxxxxxxxxxxxxxxxxxxxxxx" + } +} +``` + +**多密钥轮询以实现负载均衡:** + +```json +{ + "openrouter": { + "api_keys": [ + "sk-or-v1-key-1", + "sk-or-v1-key-2", + "sk-or-v1-key-3" + ] + } +} +``` + +**自定义 base URL(用于企业/自托管):** + +```json +{ + "openrouter": { + "base_url": "https://openrouter.mycompany.com/api/v1", + "api_key": "${OPENROUTER_API_KEY}", + "enabled": true + } +} +``` + +#### 与基于成本的路由集成 + +OpenRouter 可与 `cost_routing` 无缝配合。使用 `penalty_per_provider` 调整有效成本: + +```json +{ + "cost_routing": { + "enabled": true, + "prefer_providers": ["openrouter", "opencode-go"], + "max_context_window": 1000000, + "penalty_per_provider": { + "openrouter": 0.02, + "opencode-go": 0.0, + "aws-bedrock": 0.05 + } + } +} +``` + +惩罚值累加到原始模型成本上。例如:OpenRouter 上成本为 $0.10/1M tokens 的模型,加上 0.02 的惩罚后有效成本为 $0.12/1M tokens。用它在不完全排除提供商的情况下调整路由偏好。 + +#### 通过目录解析模型 + +模型使用 `provider/model-name` 模式引用。OpenRouter 模型使用 `openrouter/` 前缀: + +```json +{ + "model_overrides": { + "claude-opus-4": { + "provider": "openrouter", + "model_id": "anthropic/claude-opus-4", + "temperature": 0.7, + "max_tokens": 8192, + "vision": true + }, + "gpt-4o": { + "provider": "openrouter", + "model_id": "openai/gpt-4o", + "temperature": 0.7, + "max_tokens": 4096 + }, + "gemini-2.5-pro": { + "provider": "openrouter", + "model_id": "google/gemini-2.5-pro-preview-07-11", + "temperature": 0.7, + "max_tokens": 8192 + } + } +} +``` + +**发现模型:** + +1. 访问 [openrouter.ai/models](https://openrouter.ai/models) 查看完整模型列表 +2. 使用 `routatic-proxy models` 命令查看已缓存的目录条目 +3. 查阅 [OpenRouter API 文档](https://openrouter.ai/docs) 了解定价和上下文限制 + +配置中的 `model_id` 必须与 OpenRouter 的模型标识符完全一致(例如 `anthropic/claude-opus-4`、`openai/gpt-4o`、`google/gemini-2.5-pro-preview-07-11`)。 + +#### 使用场景 + +**访问特定模型:** 当你需要其他提供商上没有的模型时使用 OpenRouter: + +```json +{ + "models": { + "complex": { + "provider": "openrouter", + "model_id": "anthropic/claude-opus-4", + "temperature": 0.7, + "max_tokens": 8192, + "reasoning_effort": "max" + } + } +} +``` + +**降级链:** 在主要提供商失败时把 OpenRouter 作为降级项: + +```json +{ + "fallbacks": { + "default": [ + { "provider": "opencode-go", "model_id": "deepseek-v4-pro" }, + { "provider": "openrouter", "model_id": "anthropic/claude-sonnet-4.8" }, + { "provider": "openrouter", "model_id": "openai/gpt-4.1" } + ] + } +} +``` + +**成本优化:** 结合 `cost_routing` 和提供商惩罚,自动选择可用的最便宜模型: + +```json +{ + "cost_routing": { + "enabled": true, + "prefer_providers": ["openrouter"], + "penalty_per_provider": { + "openrouter": -0.01 + } + } +} +``` + +**专用模型:** 为特定任务访问小众模型: + +```json +{ + "models": { + "think": { + "provider": "openrouter", + "model_id": "deepseek/deepseek-r1-free", + "temperature": 0.6, + "max_tokens": 8192 + }, + "long_context": { + "provider": "openrouter", + "model_id": "google/gemini-1.5-pro", + "temperature": 0.7, + "max_tokens": 16384, + "context_threshold": 80000 + } + } +} +``` + ## 环境变量 环境变量覆盖配置文件值。配置值也支持 `${VAR}` 插值。 @@ -216,6 +437,8 @@ routatic-proxy 支持三个提供商进行上游 API 调用: | `ROUTATIC_PROXY_PORT` | 代理监听端口 | `3456` | | `ROUTATIC_PROXY_OPENCODE_URL` | OpenCode Go API 端点 | `https://opencode.ai/zen/go/v1/chat/completions` | | `ROUTATIC_PROXY_OPENCODE_ZEN_URL` | OpenCode Zen API 端点 | `https://opencode.ai/zen/v1/chat/completions` | +| `ROUTATIC_PROXY_OPENROUTER_API_KEY` | OpenRouter 单个 API key | — | +| `ROUTATIC_PROXY_OPENROUTER_API_KEYS` | OpenRouter 密钥池(逗号分隔) | — | | `ROUTATIC_PROXY_LOG_LEVEL` | 日志级别:`debug`、`info`、`warn`、`error` | `info` | 旧版等效变量如 `OC_GO_CC_API_KEY`、`OC_GO_CC_CONFIG` 和 `OC_GO_CC_PORT` 继续工作。当两者都设置时,`ROUTATIC_PROXY_*` 值优先。 @@ -240,13 +463,14 @@ kill -HUP 代理自动检测请求类型,并根据上下文大小和内容分析路由到适当的模型: -| 场景 | 触发条件 | 模型 | 原因 | -|------|----------|------|------| -| **长上下文** | >80K tokens(可配置) | MiniMax M2.7 | 1M 上下文窗口 vs 其他 128-256K | -| **复杂** | 系统提示包含 "architect"、"refactor"、"complex" | GLM-5.1 | 最佳推理和架构理解 | -| **思考** | 系统提示包含 "think"、"plan"、"reason" | GLM-5 | 良好的推理,比 GLM-5.1 便宜 | -| **后台** | "read file"、"grep"、"list directory" | Qwen3.5 Plus | 最便宜(~10K 请求/5小时),适合简单操作 | -| **默认** | 其他所有 | Kimi K2.6 | 质量与成本的最佳平衡(~1.8K 请求/5小时) | +| 场景 | 触发条件 | 默认模型 | 原因 | +|------|----------|----------|------| +| **长上下文** | >100K tokens(可配置) | `minimax-m3` | 1M 上下文窗口 | +| **视觉** | 最新的用户消息包含图像 | (未预先配置) | 拆分为 `vision` / `vision_complex` | +| **复杂** | 系统提示包含 "architect"、"refactor"、"complex" | `deepseek-v4-pro` | 最佳推理和架构理解 | +| **思考** | 系统提示包含 "think"、"plan"、"reason" | `glm-5.2` | 以较低成本获得强推理能力 | +| **后台** | "read file"、"grep"、"list directory" | `deepseek-v4-flash` | 便宜,适合简单操作 | +| **默认** | 其他所有 | `deepseek-v4-pro` | 质量与成本的最佳平衡 | **详细模型能力、成本和路由建议请参见 [MODELS.md](MODELS.md)。** @@ -256,12 +480,14 @@ DeepSeek V4 用户可以将任何场景模型设置为 `deepseek-v4-pro` 或 `de | 场景 | 触发条件 | 配置键 | 默认模型 | |------|----------|--------|----------| -| **默认** | 标准聊天 | `models.default` | `kimi-k2.6` | -| **思考** | 系统提示包含 "think"、"plan"、"reason";或思考内容块 | `models.think` | `glm-5.1` | -| **长上下文** | Token 数超过 `context_threshold` | `models.long_context` | `minimax-m2.7` | -| **后台** | 文件读取、目录列表、grep 模式 | `models.background` | `qwen3.5-plus` | +| **默认** | 标准聊天 | `models.default` | `deepseek-v4-pro` | +| **复杂** | 架构相关关键词或工具密集型操作 | `models.complex` | `deepseek-v4-pro` | +| **思考** | 系统提示包含 "think"、"plan"、"reason";或思考内容块 | `models.think` | `glm-5.2` | +| **长上下文** | Token 数超过 `context_threshold`(默认 100K) | `models.long_context` | `minimax-m3` | +| **视觉** | 最新的用户消息包含图像 | `models.vision` | (未预先配置) | +| **后台** | 文件读取、目录列表、grep 模式 | `models.background` | `deepseek-v4-flash` | -路由优先级:**长上下文** > **思考** > **后台** > **默认** +路由优先级:**长上下文** > **视觉** > **复杂** > **思考** > **后台** > **默认** ## 基于成本的路由 @@ -287,6 +513,21 @@ DeepSeek V4 用户可以将任何场景模型设置为 `deepseek-v4-pro` 或 `de | `max_context_window` | `int64` | 候选模型上下文窗口的硬上限。超过此大小的模型将被排除。`0`(默认)表示无上限。 | | `penalty_per_provider` | `map[string]float64` | 按提供商的成本惩罚,在选择时加到有效成本上。用于在不完全移除提供商的情况下使其吸引力降低。 | +启用后,`SelectCheapest` 会解析匹配场景下所有符合条件的提供商/模型组合,应用最大上下文窗口上限,按首选提供商集合过滤,并按有效成本(原始成本 + 惩罚)排序。最便宜的候选者胜出。这会取代静态的 `models.` 主模型。 + +```json +{ + "cost_routing": { + "penalty_per_provider": { + "opencode-go": 0.1, + "openrouter": 0.05 + } + } +} +``` + +惩罚值累加到原始成本上。`opencode-go` 上基础成本为 2.0 的模型加上 0.1 的惩罚后,有效成本为 2.1。 + ## 降级链 当模型请求失败(网络错误、速率限制、服务器错误)时,代理尝试降级链中的下一个模型: @@ -386,3 +627,87 @@ Claude Code 审查工作流推荐配置: ``` 将 `fast` 场景用于短/简单请求。将 `complex` 或 `long_context` 用于代码审查、多代理派发、大型差异、许多工具或长上下文 Claude Code 会话。 + +## Claude Code 模型选择器 + +你可以通过两种方式从 Claude Code 的 `/model` 选择器中选择代理模型。 + +### 直接输入任意模型名称(始终可用) + +Claude Code 的 `/model` 选择器也接受自由格式的模型名称。输入任何代理能理解的值——场景别名(`default`、`fast`、`complex` 等)、`model_overrides` 键,或像 `opencode-go/kimi-k2.6` 这样的目录规范名称——代理都会完成路由。无需额外配置;无论 Claude Code 版本如何,此方式均有效。 + +### 网关模型发现(可选启用,会向选择器添加条目) + +较新版本的 Claude Code 可以通过查询代理的 [`GET /v1/models`](../reference-api.md#get-v1models) 端点自动填充选择器。启用后,发现的模型会与内置条目(Sonnet、Opus 等)一起出现在 `/model` 中,并标记为 **"From gateway"**。 + +在设置 `ANTHROPIC_BASE_URL` 的同时按如下方式启用: + +```bash +export ANTHROPIC_BASE_URL=http://127.0.0.1:3456 +export ANTHROPIC_AUTH_TOKEN=unused +export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 +``` + +只有在以下条件全部满足时才会运行发现:已设置 `ANTHROPIC_BASE_URL`、`CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`、未设置任何 `CLAUDE_CODE_USE_*` 提供商变量、base URL 不是 `api.anthropic.com`,以及 Claude Code 版本支持该功能(≥ 2.1.129)。结果会缓存到 `~/.claude/cache/gateway-models.json`。 + +> **重要 —— Claude Code 会过滤发现到的模型 ID。** Claude Code 只显示 `id` 以 **`claude`** 或 **`anthropic`** 开头的发现模型。因此代理的场景别名(`default`、`fast` 等)和目录名称(`opencode-go/kimi-k2.6`)会**被选择器过滤掉**。要让某个代理模型通过发现出现,请给它一个 `claude-*` 名称——最自然的做法是使用 [`model_overrides`](#模型覆盖model_overrides) 键: +> +> ```json +> { +> "model_overrides": { +> "claude-glm-5.2": { "provider": "opencode-go", "model_id": "glm-5.2" } +> } +> } +> ``` +> +> 之后 `claude-glm-5.2` 就会出现在选择器中(标记为 "From gateway"),选中它会路由到 GLM-5.2。ID 不以 `claude`/`anthropic` 开头的模型仍然完全可用——只需直接在 `/model` 中输入即可。 + +## 配合 CC-Switch 使用 + +[CC-Switch](https://github.com/farion1231/cc-switch) 是一个用于管理和热切换 Claude Code 提供商的桌面应用。routatic-proxy 开箱即可与它配合——代理讲的正是 Claude Code(因而也是 CC-Switch)本来就期待的 Anthropic API,所以你可以像添加任何其他自定义提供商一样添加它。 + +### 将 routatic-proxy 添加为自定义提供商 + +1. 启动代理:`routatic-proxy serve`(默认监听地址 `http://127.0.0.1:3456`)。 +2. 在 CC-Switch 中,点击 **Add Provider → Custom** 并填写: + + | CC-Switch 字段 | 值 | + |----------------|-----| + | **Name** | `routatic-proxy`(任意标签) | + | **Endpoint URL** | `http://127.0.0.1:3456` | + | **API Key** | 任意非空值(例如 `unused`)—— 见下方说明 | + + CC-Switch 会把这些写入 Claude Code 的配置: + + ```json + { + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:3456", + "ANTHROPIC_AUTH_TOKEN": "unused" + } + } + ``` + + 这正是代理所依赖的那两个环境变量——与 [README](../../README-zh.md) 中手动快速上手部分使用的相同。 +3. **启用** 该提供商。Claude Code 会热重载它,因此无需重启。 + +> **关于 API Key 字段:** `ANTHROPIC_AUTH_TOKEN` 中的令牌是 Claude Code 发送给*代理*的内容,而不是代理向上游发送的内容。你真正的上游密钥存放在代理自己的配置(`opencode_go.api_key`、`openrouter.api_key` 等)或环境变量(`ROUTATIC_PROXY_*`)中。如果你在代理配置中设置了 `api_key` / `api_keys`,该值必须与 CC-Switch 发送的一致;如果你没有设置代理端认证,则任意非空令牌均可。 + +### 配置特定模型 + +你有两种方式控制经由 CC-Switch 选择的请求运行在哪个模型上: + +- **让 Claude Code 选择并予以尊重** —— 当 `respect_requested_model: true`(默认值)时,代理会使用 Claude Code 发送的任何模型字符串,并对照你的 `models` 配置和目录进行解析。设为 `false` 可强制使用场景路由,忽略请求的模型。 +- **固定一个模型别名** —— 使用 [`model_overrides`](#模型覆盖model_overrides) 把客户端可见的模型名称映射到固定的上游模型。例如,请求 `claude-sonnet-4.5` 可以路由到你选择的任意提供商/模型。 + +### CC-Switch 的 "Fetch Models" 按钮 + +CC-Switch 的自定义提供商表单有一个 **Fetch Models** 按钮,它调用 OpenAI 风格的 `GET /v1/models` 端点来填充模型下拉列表。代理实现了此端点:它返回你可以请求的每一个模型标识符——配置中的 `models` 别名、`model_overrides` 键,以及目录规范名称(`provider/model`)。参见 [docs/reference-api.md](../reference-api.md#get-v1models)。 + +如果下拉列表看起来很短,通常意味着模型目录尚未同步到本地存储;场景别名(`default`、`fast`、`complex` 等)和任何 `model_overrides` 键始终会出现。 + +### 故障排查 + +- **CC-Switch 报告提供商不可达** —— 确认代理正在运行(`routatic-proxy status`),且端点 URL/端口与代理配置中的 `host`/`port` 一致。 +- **代理返回 401 / 认证错误** —— CC-Switch 发送的令牌必须满足代理的 `api_key` / `api_keys`(或这些项未设置)。这是代理端的认证,与你的上游提供商密钥无关。 +- **运行了错误的模型** —— 检查路由优先级:`model_overrides` 优先,然后是 `respect_requested_model`,最后是场景路由。参见 [路由优先级](#路由优先级)。 diff --git a/docs/zh/INSTALLATION.md b/docs/zh/INSTALLATION.md index 0212a83f..657129e7 100644 --- a/docs/zh/INSTALLATION.md +++ b/docs/zh/INSTALLATION.md @@ -2,8 +2,6 @@ [English](../../INSTALLATION.md) | **中文** -> **注意:** 本翻译可能落后于英文原版。如有疑问,请以 [INSTALLATION.md](../../INSTALLATION.md)(英文)为准。 - ## Homebrew(macOS 和 Linux) ```bash @@ -59,6 +57,24 @@ Homebrew 和 Scoop 安装也提供 `oc-go-cc` 作为 `routatic-proxy` 的别名 ## Docker +### 拉取预构建镜像 + +预构建的多架构镜像(linux/amd64、linux/arm64)发布在 GitHub Container Registry: + +```bash +# 最新稳定版 +docker pull ghcr.io/routatic/proxy:latest + +# 最新 beta 版(最新的预发布构建) +docker pull ghcr.io/routatic/proxy:beta + +# 特定的稳定版本 +docker pull ghcr.io/routatic/proxy:v1.0.0 + +docker run -d --restart unless-stopped --name routatic-proxy \ + --env-file .env -p 3456:3456 ghcr.io/routatic/proxy:latest +``` + ### 使用 Makefile 快速启动 ```bash diff --git a/docs/zh/MODELS.md b/docs/zh/MODELS.md index f9bc560c..f26664c9 100644 --- a/docs/zh/MODELS.md +++ b/docs/zh/MODELS.md @@ -2,8 +2,6 @@ [English](../../MODELS.md) | **中文** -> **注意:** 本翻译可能落后于英文原版。如有疑问,请以 [MODELS.md](../../MODELS.md)(英文)为准。 - OpenCode Go 和 Zen 模型的综合指南,包括能力、成本和路由建议。 **来源:** [OpenCode Go 文档](https://opencode.ai/docs/go/) | [OpenCode Zen 文档](https://opencode.ai/docs/zen/) @@ -52,6 +50,290 @@ OpenCode Go 和 Zen 模型的综合指南,包括能力、成本和路由建议 - 为 Claude 和其他 Anthropic 原生模型设置 `wire_format: "anthropic"` - 最适合:部署在自己 AWS 基础设施上的模型 +## OpenRouter 模型 + +OpenRouter 通过单一 API 端点提供**对 100+ 个模型的统一访问**,这些模型来自多个提供商。你不需要为每个提供商分别管理 API 密钥和端点,只需一次集成即可访问 OpenAI、Anthropic、Google、Meta、Mistral 等众多提供商的模型。 + +### 主要优势 + +| 优势 | 说明 | +|------|------| +| **统一 API** | 所有模型共用一个与 OpenAI 兼容的 Chat Completions 端点 | +| **100+ 个模型** | 访问 20+ 个提供商的模型,无需分别集成 | +| **动态目录** | 目录更新后,新模型自动可用 | +| **按量付费** | 按 token 计价,无需订阅 | +| **智能路由** | 模型不可用时自动降级到其他提供商 | +| **成本优化** | 路由到最便宜或最快的可用提供商 | + +### 模型命名约定 + +OpenRouter 使用 `provider/model-name` 格式,同时标识原始提供商和具体模型: + +| 格式 | 示例 | 说明 | +|------|------|------| +| `openai/gpt-4o` | `openai/gpt-4o` | 通过 OpenAI 提供的 GPT-4o | +| `openai/gpt-4o-mini` | `openai/gpt-4o-mini` | 通过 OpenAI 提供的 GPT-4o Mini | +| `anthropic/claude-3.5-sonnet` | `anthropic/claude-3.5-sonnet` | 通过 Anthropic 提供的 Claude 3.5 Sonnet | +| `anthropic/claude-3-opus` | `anthropic/claude-3-opus` | 通过 Anthropic 提供的 Claude 3 Opus | +| `google/gemini-2.5-pro` | `google/gemini-2.5-pro` | 通过 Google 提供的 Gemini 2.5 Pro | +| `google/gemini-2.0-flash` | `google/gemini-2.0-flash` | 通过 Google 提供的 Gemini 2.0 Flash | +| `meta-llama/llama-3.3-70b` | `meta-llama/llama-3.3-70b` | 通过 Meta 提供的 Llama 3.3 70B | +| `mistral/mistral-large` | `mistral/mistral-large` | 通过 Mistral AI 提供的 Mistral Large | +| `x-ai/grok-2` | `x-ai/grok-2` | 通过 xAI 提供的 Grok 2 | +| `deepseek/deepseek-chat` | `deepseek/deepseek-chat` | 通过 DeepSeek 提供的 DeepSeek V3 | + +### 热门 OpenRouter 模型 + +| 模型 | 提供商 | 上下文窗口 | 输入成本($/M) | 输出成本($/M) | 最适合 | +|------|--------|------------|-----------------|-----------------|--------| +| **Claude 3.5 Sonnet** | Anthropic | 200K | $3.00 | $15.00 | 复杂推理、编码、分析 | +| **Claude 3 Opus** | Anthropic | 200K | $15.00 | $75.00 | 最高质量、困难任务 | +| **GPT-4o** | OpenAI | 128K | $2.50 | $10.00 | 通用用途、视觉任务 | +| **GPT-4o Mini** | OpenAI | 128K | $0.15 | $0.60 | 经济高效、大批量 | +| **Gemini 2.5 Pro** | Google | 1M | $1.25 | $10.00 | 长上下文、编码、推理 | +| **Gemini 2.0 Flash** | Google | 1M | $0.10 | $0.40 | 快速响应、成本效率 | +| **Llama 3.3 70B** | Meta | 128K | $0.12 | $0.30 | 开源、可定制 | +| **Llama 3.1 405B** | Meta | 128K | $0.80 | $1.60 | 开源、高质量 | +| **Mistral Large** | Mistral | 128K | $2.00 | $6.00 | 欧洲提供商、符合 GDPR | +| **Grok 2** | xAI | 128K | $2.00 | $10.00 | 实时知识、幽默 | +| **DeepSeek V3** | DeepSeek | 64K | $0.07 | $1.10 | 成本效率、编码 | +| **DeepSeek R1** | DeepSeek | 64K | $0.55 | $2.19 | 推理、思维链 | + +### 发现可用模型 + +在此浏览完整模型目录:**https://openrouter.ai/models** + +目录包含每个模型的详细信息: +- **价格**:每百万 token 的输入和输出成本 +- **上下文窗口**:模型可处理的最大 token 数 +- **工具调用**:模型是否支持函数调用 +- **视觉**:模型是否支持图像输入 +- **提供商路由**:每个模型的可用提供商 + +你也可以通过 OpenRouter API 以编程方式获取可用模型: + +```bash +curl https://openrouter.ai/api/v1/models \ + -H "Authorization: Bearer $OPENROUTER_API_KEY" +``` + +### 配置 OpenRouter 模型 + +将 OpenRouter 模型添加到你的 routatic-proxy 配置中: + +```json +{ + "models": { + "default": { + "provider": "openrouter", + "model_id": "anthropic/claude-3.5-sonnet", + "temperature": 0.7, + "max_tokens": 4096 + }, + "background": { + "provider": "openrouter", + "model_id": "openai/gpt-4o-mini", + "temperature": 0.5, + "max_tokens": 2048 + }, + "complex": { + "provider": "openrouter", + "model_id": "anthropic/claude-3-opus", + "temperature": 0.7, + "max_tokens": 8192 + }, + "long_context": { + "provider": "openrouter", + "model_id": "google/gemini-2.5-pro", + "temperature": 0.7, + "max_tokens": 8192 + } + } +} +``` + +### 与基于成本的路由集成 + +设置 `cost_routing.enabled` 后,选择器会自动按成本对模型排序,并应用你的路由偏好: + +```json +{ + "cost_routing": { + "enabled": true, + "prefer_providers": ["opencode-go", "openrouter"], + "penalty_per_provider": { + "openrouter": 0.05 + }, + "max_context_window": 200000 + } +} +``` + +**成本路由与 OpenRouter 的配合方式:** +- 模型按输入 + 输出费率之和排序 +- 提供商惩罚会调整实际成本(例如 `openrouter: 0.05` 增加 5%) +- `max_context_window` 会过滤掉无法处理你请求规模的模型 +- `prefer_providers` 与场景偏好取交集,得出最终选择 + +### 可用的模型类别 + +| 类别 | 提供商 | 示例模型 | +|------|--------|----------| +| **OpenAI** | OpenAI、Azure | GPT-4o、GPT-4o Mini、GPT-4 Turbo | +| **Anthropic** | Anthropic、AWS | Claude 3 Opus、Claude 3.5 Sonnet、Claude 3 Haiku | +| **Google** | Google | Gemini 2.5 Pro、Gemini 2.0 Flash、Gemini 1.5 Pro | +| **Meta** | Meta、Together、Fireworks | Llama 3.3 70B、Llama 3.1 405B、Llama 3.1 70B | +| **Mistral** | Mistral AI | Mistral Large、Mistral Medium、Mistral Small | +| **xAI** | xAI | Grok 2、Grok Beta | +| **DeepSeek** | DeepSeek | DeepSeek V3、DeepSeek R1 | +| **专用** | 多家 | Qwen、Command R+、Perplexity 等许多模型 | + +### 配置示例 + +#### 注重预算的方案 +大多数任务使用较便宜的模型,仅在必要时使用昂贵模型: + +```json +{ + "models": { + "background": { + "provider": "openrouter", + "model_id": "openai/gpt-4o-mini" + }, + "default": { + "provider": "openrouter", + "model_id": "deepseek/deepseek-chat" + }, + "complex": { + "provider": "openrouter", + "model_id": "anthropic/claude-3.5-sonnet" + } + } +} +``` + +#### 质量优先的方案 +为关键任务优先保证质量: + +```json +{ + "models": { + "default": { + "provider": "openrouter", + "model_id": "anthropic/claude-3.5-sonnet" + }, + "complex": { + "provider": "openrouter", + "model_id": "anthropic/claude-3-opus" + }, + "long_context": { + "provider": "openrouter", + "model_id": "google/gemini-2.5-pro" + } + } +} +``` + +#### 多提供商降级 +将请求分散到多个提供商以提高可靠性: + +```json +{ + "models": { + "default": { + "provider": "openrouter", + "model_id": "openai/gpt-4o" + }, + "fallback": { + "provider": "openrouter", + "model_id": "anthropic/claude-3.5-sonnet" + } + }, + "fallbacks": { + "default": [ + { "model_id": "openai/gpt-4o" }, + { "model_id": "anthropic/claude-3.5-sonnet" }, + { "model_id": "google/gemini-2.5-pro" } + ] + } +} +``` + +### OpenRouter 专有特性 + +#### 提供商路由偏好 +指定特定提供商或启用自动降级: + +```json +{ + "model_overrides": { + "claude-sonnet": { + "provider": "openrouter", + "model_id": "anthropic/claude-3.5-sonnet", + "temperature": 0.7, + "max_tokens": 4096, + "extra_body": { + "provider": { + "order": ["Anthropic", "AWS"], + "allow_fallbacks": true + } + } + } + } +} +``` + +**路由选项:** +- `order`:要尝试的提供商优先级列表 +- `allow_fallbacks`:主提供商不可用时是否尝试其他提供商 +- `ignore`:从路由中排除的提供商 + +#### OpenRouter 模型目录的工作方式 + +OpenRouter 模型通过目录系统动态解析: + +1. **目录位置:** `~/.config/routatic-proxy/catalog/catalog.json` +2. **解析方式:** 模型以 `provider/model-name` 作为键(例如 `openai/gpt-4o`、`anthropic/claude-3.5-sonnet`) +3. **动态加载:** 目录更新后新模型自动可用 —— 无需修改代码 + +#### 从目录解析模型 + +目录系统从键前缀中提取提供商: + +```json +{ + "providers": { + "openrouter": { + "name": "OpenRouter", + "base_url": "https://openrouter.ai/api/v1", + "enabled": true + } + }, + "models": { + "openrouter/anthropic/claude-3.5-sonnet": { + "id": "openrouter/anthropic/claude-3.5-sonnet", + "name": "Claude 3.5 Sonnet", + "limit": { "context": 200000 }, + "rates": { "input": 3.0, "output": 15.0 }, + "tool_call": true, + "modalities": { "input": ["text", "image"], "output": ["text"] }, + "reasoning": false + } + } +} +``` + +- `ResolvedModel.ModelID`:不含提供商前缀的模型名称(`anthropic/claude-3.5-sonnet`) +- `ResolvedModel.CanonicalName`:完整键(`openrouter/anthropic/claude-3.5-sonnet`) + +### 优势总结 + +1. **访问 100+ 个模型:** 一个 API 密钥即可使用 OpenAI、Anthropic、Google、Meta、Mistral 等 +2. **统一 API:** 所有模型都使用与 OpenAI 兼容的 Chat Completions 格式 +3. **自动降级:** 模型不可用时内置降级到其他提供商 +4. **成本优化:** 按 token 计价,并可通过路由偏好提升成本效率 +5. **无供应商锁定:** 只需更改模型 ID 即可切换提供商 + ## 重要:API 端点 ⚠️ **关键:** 不是所有模型都使用相同的 API 端点!routatic-proxy 自动处理这个问题,但你应该了解: @@ -67,10 +349,10 @@ OpenCode Go 和 Zen 模型的综合指南,包括能力、成本和路由建议 | 模型 | 端点 | 格式 | |------|------|------| -| MiniMax, GLM, Kimi, DeepSeek, 免费层模型 | `https://opencode.ai/zen/v1/chat/completions` | OpenAI 兼容 | -| **Claude 模型**, **Qwen 模型** | `https://opencode.ai/zen/v1/messages` | **Anthropic 兼容** | -| **GPT 模型** | `https://opencode.ai/zen/v1/responses` | **OpenAI Responses** | -| **Gemini 模型** | `https://opencode.ai/zen/v1/models/{id}` | **Google Gemini** | +| MiniMax M2.5, MiniMax M2.7, MiniMax M3, GLM-5, GLM-5.1, GLM-5.2, Kimi K2.5, Kimi K2.6, Kimi K2.7 Code, Kimi K3, DeepSeek V4 Pro, DeepSeek V4 Flash, DeepSeek V4 Flash Free, Grok Build 0.1, Big Pickle, MiMo-V2.5 Free, North Mini Code Free, Nemotron 3 Ultra Free | `https://opencode.ai/zen/v1/chat/completions` | OpenAI 兼容 | +| **Claude 模型**(claude-fable-5, claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-opus-4-5, claude-opus-4-1, claude-sonnet-4-6, claude-sonnet-4-5, claude-sonnet-4, claude-haiku-4-5, claude-3-5-haiku),**Qwen 模型**(qwen3.5-plus, qwen3.6-plus, qwen3.7-plus, qwen3.7-max) | `https://opencode.ai/zen/v1/messages` | **Anthropic 兼容** | +| **GPT 模型**(gpt-5.5, gpt-5.5-pro, gpt-5.5-mini, gpt-5.5-nano, gpt-5.4, gpt-5.4-pro, gpt-5.4-mini, gpt-5.4-nano, gpt-5.3-codex, gpt-5.3-codex-spark, gpt-5.2, gpt-5.2-codex, gpt-5.1, gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5, gpt-5-codex, gpt-5-nano) | `https://opencode.ai/zen/v1/responses` | **OpenAI Responses** | +| **Gemini 模型**(gemini-3.5-flash, gemini-3.1-pro, gemini-3-flash) | `https://opencode.ai/zen/v1/models/{id}` | **Google Gemini** | **为什么这很重要:** 在 Go 提供商上,MiniMax 和 Qwen 模型原生使用 Anthropic 格式。在 Zen 上,只有 Claude 和 Qwen 使用 Anthropic 端点 —— MiniMax 使用 chat completions。routatic-proxy 自动处理所有路由。 @@ -96,9 +378,9 @@ OpenCode Go 和 Zen 模型的综合指南,包括能力、成本和路由建议 所有 OpenCode Go 模型也可在 Zen 上使用。Zen 还额外提供: - **Claude 模型(Anthropic 端点):** claude-fable-5, claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-opus-4-5, claude-opus-4-1, claude-sonnet-4-6, claude-sonnet-4-5, claude-sonnet-4, claude-haiku-4-5, claude-3-5-haiku -- **GPT 模型(Responses 端点):** gpt-5.5, gpt-5.5-pro, gpt-5.4, gpt-5.4-pro, gpt-5.4-mini, gpt-5.4-nano, gpt-5.3-codex 等 +- **GPT 模型(Responses 端点):** gpt-5.5, gpt-5.5-pro, gpt-5.5-mini, gpt-5.5-nano, gpt-5.4, gpt-5.4-pro, gpt-5.4-mini, gpt-5.4-nano, gpt-5.3-codex, gpt-5.3-codex-spark, gpt-5.2, gpt-5.2-codex, gpt-5.1, gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5, gpt-5-codex, gpt-5-nano - **Gemini 模型(Gemini 端点):** gemini-3.5-flash, gemini-3.1-pro, gemini-3-flash -- **免费层(chat completions):** deepseek-v4-pro, deepseek-v4-flash-free, grok-build-0.1, big-pickle, mimo-v2.5-free, north-mini-code-free, nemotron-3-ultra-free +- **免费层(chat completions):** deepseek-v4-flash-free, big-pickle, mimo-v2.5-free, north-mini-code-free, nemotron-3-ultra-free #### 已弃用的 Zen 模型 @@ -183,9 +465,9 @@ DeepSeek V4 Pro 和 Flash 在 Go 和 Zen 提供商上都是 OpenAI 兼容的。D "max_tokens": 4096 }, "long_context": { - // 仅大文件 - "model_id": "minimax-m2.5", - "context_threshold": 80000 + // 仅大文件 —— 需要 1M 上下文的模型 + "model_id": "minimax-m3", + "context_threshold": 100000 }, "think": { // 推理任务 @@ -209,8 +491,8 @@ DeepSeek V4 Pro 和 Flash 在 Go 和 Zen 提供商上都是 OpenAI 兼容的。D ### 决策树 ``` -上下文是否 > 80K tokens? -├── 是 → 使用 MiniMax M2.5(1M 上下文,6,300 请求/$12) +上下文是否 > 100K tokens?(默认阈值,可通过 context_threshold 配置) +├── 是 → 使用 MiniMax M3(1M 上下文,3,200 请求/$12) │ 是否是复杂任务(架构、重构、工具操作)? ├── 是 → 使用 GLM-5.1(880 请求/$12) @@ -232,8 +514,9 @@ DeepSeek V4 Pro 和 Flash 在 Go 和 Zen 提供商上都是 OpenAI 兼容的。D - **模型 ID:** `qwen3.5-plus` - **成本:** **每 $12 10,200 次请求**(最佳性价比!) -- **上下文:** ~128K tokens +- **上下文:** **~1M tokens** - **质量:** ★★☆☆☆(适合简单任务) +- **模态:** 文本和图像输入 - **最适合:** - 文件读取操作 - 目录列表 @@ -243,31 +526,35 @@ DeepSeek V4 Pro 和 Flash 在 Go 和 Zen 提供商上都是 OpenAI 兼容的。D - 后台任务 - **何时使用:** 当你需要大量操作且成本低廉时 -#### MiniMax M2.5 —— 预算长上下文 +#### MiniMax M2.5 —— 最便宜的 200K 级模型 - **模型 ID:** `minimax-m2.5` - **端点:** **Anthropic 兼容**(Go 上 `/v1/messages`),**OpenAI 兼容**(Zen 上 `/chat/completions`) - **成本:** **每 $12 6,300 次请求** -- **上下文:** **~1M tokens**(100 万!) +- **上下文:** ~200K tokens +- **最大输出:** 4K tokens - **质量:** ★★☆☆☆(可接受) - **速度:** 快 - **最适合:** - - 超大文件 - - 长对话 + - 仍能放入 200K 的大文件 + - 预算紧张时的长对话 - 多文件上下文 -- **何时使用:** 当你需要 1M 上下文但想最小化成本时 +- **何时使用:** 当 200K 上下文足够且成本优先时。真正的长上下文(>100K,最高 1M)请改用 MiniMax M3。 +- **注意:** 在 Go 上使用 Anthropic 端点,但在 Zen 上使用 chat completions —— routatic-proxy 会自动处理 #### MiniMax M3 —— 最新 MiniMax,1M 上下文 - **模型 ID:** `minimax-m3` - **端点:** **Anthropic 兼容**(Go 上 `/v1/messages`),**OpenAI 兼容**(Zen 上 `/chat/completions`) +- **成本:** **每 $12 3,200 次请求** - **上下文:** **~1M tokens** +- **最大输出:** 128K tokens - **质量:** ★★★☆☆ - **最适合:** - - 需要比 M2.5 更好质量的长上下文任务 + - 长上下文任务(推荐的 `long_context` 模型) - 大型代码库分析 - 文档处理 -- **何时使用:** 当你需要 1M 上下文且想要比 M2.5 更好的质量时 +- **何时使用:** 只要请求超过长上下文阈值就用它 —— M2.5 上限为 200K,M3 可达 1M ### 平衡模型(质量 + 成本) @@ -345,8 +632,9 @@ DeepSeek V4 Pro 和 Flash 在 Go 和 Zen 提供商上都是 OpenAI 兼容的。D - **模型 ID:** `qwen3.6-plus` - **端点:** **Anthropic 兼容**(`/v1/messages` —— Go),**Anthropic 兼容**(`/v1/messages` —— Zen) - **成本:** **每 $12 3,300 次请求**(比 GLM-5.1 多 3.8 倍!) -- **上下文:** ~128K tokens +- **上下文:** **~1M tokens** - **质量:** ★★★☆☆(对大多数任务足够好) +- **模态:** 文本和图像输入 - **速度:** 快 - **最适合:** - 通用编码(默认选择) @@ -355,31 +643,22 @@ DeepSeek V4 Pro 和 Flash 在 Go 和 Zen 提供商上都是 OpenAI 兼容的。D - 重构 - **何时使用:** 注重成本用户的默认选择 -#### Qwen3.7 Plus —— 升级版通用编码 - -- **模型 ID:** `qwen3.7-plus` -- **端点:** **Anthropic 兼容**(`/v1/messages`) -- **成本:** **每 $12 4,300 次请求** -- **上下文:** ~128K tokens -- **质量:** ★★★★☆ -- **速度:** 快 -- **最适合:** - - 比 Qwen3.6 质量更好的通用编码 - - 功能实现 - - Bug 修复 -- **何时使用:** 当你想要比 Qwen3.6 更好的质量且速度相近时 +**Qwen3.7 Plus / Max** —— 见下方“高级模型”章节。 -#### Qwen3.7 Max —— 最大质量 Qwen +#### Kimi K2.6 —— 平衡成本下的最佳质量 -- **模型 ID:** `qwen3.7-max` -- **端点:** **Anthropic 兼容**(`/v1/messages`) -- **成本:** **每 $12 950 次请求** -- **上下文:** ~128K tokens -- **质量:** ★★★★☆ +- **模型 ID:** `kimi-k2.6` +- **成本:** **每 $12 ~1,850 次请求** +- **上下文:** ~256K tokens(K2.5 的后继版本,有多项改进) +- **质量:** ★★★★★(优秀 —— 后继版本的改进) +- **模态:** 文本和图像输入 +- **速度:** 快 - **最适合:** - 复杂编码任务 - - 当 Qwen3.7 Plus 不够时 -- **何时使用:** 当你需要 Qwen 的最佳质量时 + - 代码审查 + - 架构讨论 + - 通用默认(最佳质量成本比) +- **何时使用:** 默认选择 —— 比 K2.5 更好的质量,成本相近 #### Kimi K2.5 —— 质量 + 合理成本(前代) @@ -387,6 +666,7 @@ DeepSeek V4 Pro 和 Flash 在 Go 和 Zen 提供商上都是 OpenAI 兼容的。D - **成本:** **每 $12 1,850 次请求** - **上下文:** ~256K tokens(是大多数模型的两倍) - **质量:** ★★★★☆(优秀) +- **模态:** 文本和图像输入 - **速度:** 快 - **最适合:** - 复杂编码任务 @@ -395,20 +675,6 @@ DeepSeek V4 Pro 和 Flash 在 Go 和 Zen 提供商上都是 OpenAI 兼容的。D - 当你需要比预算模型更好的质量时 - **何时使用:** 当质量比最大成本节省更重要时 -#### Kimi K2.6 —— 平衡成本下的最佳质量 - -- **模型 ID:** `kimi-k2.6` -- **成本:** **每 $12 ~1,850 次请求** -- **上下文:** ~256K tokens -- **质量:** ★★★★★(优秀) -- **速度:** 快 -- **最适合:** - - 复杂编码任务 - - 代码审查 - - 架构讨论 - - 通用默认(最佳质量成本比) -- **何时使用:** 默认选择 —— 比 K2.5 更好的质量,成本相近 - ### 高级模型(谨慎使用!) #### GLM-5 —— 推理专家 @@ -452,6 +718,23 @@ DeepSeek V4 Pro 和 Flash 在 Go 和 Zen 提供商上都是 OpenAI 兼容的。D - 生产代码审查 - **何时使用:** 使用此模型替代 GLM-5.1 以获得最新改进 +#### Kimi K3 —— 最新 Kimi 旗舰 + +- **模型 ID:** `kimi-k3` +- **提供商:** OpenCode Go(上游为 Moonshot AI) +- **端点:** OpenAI 兼容(`/v1/chat/completions`) +- **上下文:** 1M tokens +- **质量:** ★★★★★ +- **最大输出:** 131K tokens +- **模态:** 文本、图像和视频输入 +- **成本:** 每 1M 输入 tokens $3.00 · 每 1M 输出 tokens $15.00 +- **发布时间:** 2026 年 7 月 +- **最适合:** + - 最新一代代码生成和代理式工具调用 + - 长上下文工作(1M 窗口)和超长输出 + - 多模态任务(图像/视频输入) +- **何时使用:** 当你想使用最新一代 Kimi 时;降级顺序为 Kimi K2.7 Code,然后 Kimi K2.6 + #### Kimi K2.7 Code —— 代码专家 - **模型 ID:** `kimi-k2.7-code` @@ -459,13 +742,43 @@ DeepSeek V4 Pro 和 Flash 在 Go 和 Zen 提供商上都是 OpenAI 兼容的。D - **上下文:** ~256K tokens - **质量:** ★★★★★(代码任务优秀) - **最大输出:** 32K tokens(最高可用!) +- **模态:** 文本和图像输入 - **速度:** 快 - **最适合:** - 大型代码生成任务 - 需要长输出的复杂重构 - 详细反馈的代码审查 + - 当你需要最高输出 token 上限时 - **何时使用:** 当你需要高质量和超长输出(最多 32K)时 +#### Qwen3.7 Plus —— 升级版通用编码 + +- **模型 ID:** `qwen3.7-plus` +- **端点:** **Anthropic 兼容**(`/v1/messages`) +- **成本:** **每 $12 4,300 次请求** +- **上下文:** **~1M tokens** +- **质量:** ★★★★☆ +- **模态:** 文本和图像输入 +- **速度:** 快 +- **最适合:** + - 比 Qwen3.6 质量更好的通用编码 + - 功能实现 + - Bug 修复 +- **何时使用:** 当你想要比 Qwen3.6 更好的质量且速度相近时 + +#### Qwen3.7 Max —— 最大质量 Qwen + +- **模型 ID:** `qwen3.7-max` +- **端点:** **Anthropic 兼容**(`/v1/messages`) +- **成本:** **每 $12 950 次请求** +- **上下文:** **~1M tokens** +- **质量:** ★★★★☆ +- **模态:** 文本和图像输入 +- **最适合:** + - 复杂编码任务 + - 当 Qwen3.7 Plus 不够时 +- **何时使用:** 当你需要 Qwen 的最佳质量时 + ## 使用限制 OpenCode Go 限制: @@ -517,7 +830,13 @@ OpenCode Go 限制: { "model_id": "qwen3.6-plus" }, { "model_id": "minimax-m2.5" } ], - "long_context": [{ "model_id": "minimax-m2.7" }], + "long_context": [ + { "provider": "opencode-go", "model_id": "qwen3.7-plus" }, + { "provider": "opencode-go", "model_id": "qwen3.7-max" }, + { "provider": "opencode-zen", "model_id": "nemotron-3-ultra-free" }, + { "provider": "opencode-zen", "model_id": "mimo-v2.5-free" }, + { "provider": "opencode-zen", "model_id": "deepseek-v4-flash-free" } + ], "default": [{ "model_id": "mimo-v2.5-pro" }, { "model_id": "qwen3.6-plus" }], "think": [{ "model_id": "kimi-k2.6" }], "complex": [{ "model_id": "glm-5" }], @@ -535,7 +854,7 @@ OpenCode Go 限制: | 读文件、ls、grep | Qwen3.5 Plus | 10,200 | Qwen3.6 Plus | | 通用编码 | Qwen3.7 Plus | 4,300 | Qwen3.6 Plus | | 复杂功能 | Kimi K2.6 | 1,850 | MiMo-V2.5-Pro | -| 长上下文(>80K)| MiniMax M2.5 | 6,300 | MiniMax M2.7 | +| 长上下文(>100K)| MiniMax M3 | 3,200 | Qwen3.7 Plus | | 推理/规划 | GLM-5 | 1,150 | Kimi K2.6 | | 关键架构 | GLM-5.2 | 880 | GLM-5.1 | | 代码专家 | Kimi K2.7 Code | 1,350 | Kimi K2.6 | @@ -546,8 +865,8 @@ OpenCode Go 限制: 1. **将 Qwen3.6 Plus 作为默认** — 3,300 请求/$12 对大多数任务足够 2. **仅在关键任务使用 GLM-5.1** — 880 请求/$12 快速消耗预算 3. **简单操作使用 Qwen3.5 Plus** — 10,200 请求/$12 无敌 -4. **长上下文使用 MiniMax M2.5** — 6,300 请求/$12 加 1M 上下文性价比惊人 -5. **非关键任务使用 Zen 免费层模型** — deepseek-v4-pro、grok-build-0.1、big-pickle 等 $0 +4. **长上下文使用 MiniMax M3** — 3,200 请求/$12 加 1M 窗口;只要能放进 200K 窗口,MiniMax M2.5 仍是每 $12 6,300 次请求的预算之选 +5. **非关键任务使用 Zen 免费层模型** — Nemotron 3 Ultra Free、MiMo V2.5 Free、DeepSeek V4 Flash Free、Big Pickle 等模型在促销有效期内成本为 $0 6. **在 [OpenCode 控制台](https://opencode.ai/auth) 监控使用量** ## 另请参阅 diff --git a/docs/zh/TROUBLESHOOTING.md b/docs/zh/TROUBLESHOOTING.md index e95a840b..9d04c700 100644 --- a/docs/zh/TROUBLESHOOTING.md +++ b/docs/zh/TROUBLESHOOTING.md @@ -2,8 +2,6 @@ [English](../../TROUBLESHOOTING.md) | **中文** -> **注意:** 本文档与英文原版内容存在差异(当前中文版包含英文版尚未收录的章节)。两个版本请交叉参考:[TROUBLESHOOTING.md](../../TROUBLESHOOTING.md)(英文)。 - ## Windows Scoop 后台模式 在 Windows 上,`routatic-proxy serve -b` 使用原生 Windows 进程 API,并保持 Scoop shim 路径不变。这意味着后台模式不需要 `nohup` 或类似 Unix 的 shell,Scoop 提供的环境变量继续工作。 diff --git a/internal/handlers/messages.go b/internal/handlers/messages.go index 02da4b1c..9c5f5158 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -481,6 +481,7 @@ func (h *MessagesHandler) HandleMessages(w http.ResponseWriter, r *http.Request) "model", routeResult.Primary.ModelID, "provider", routeResult.Primary.Provider, "tokens", tokenCount, + "reason", routeResult.Reason, ) normalizedReq := core.NormalizeRequest(&anthropicReq) diff --git a/internal/router/model_router.go b/internal/router/model_router.go index 34cb9618..26cffd33 100644 --- a/internal/router/model_router.go +++ b/internal/router/model_router.go @@ -77,10 +77,30 @@ func isRespectRequestedModel(cfg *config.Config) bool { } // RouteResult contains the selected model and fallback chain. +// +// Reason is the human-readable routing explanation used by logs and the +// dry-run/debug paths. It combines the scenario trigger (why this scenario +// matched) with the model that was actually resolved for it — always read from +// config or the catalog, never a hardcoded name, so it cannot drift. type RouteResult struct { Primary config.ModelConfig Fallbacks []config.ModelConfig Scenario Scenario + Reason string +} + +// describeRouting pairs a scenario trigger with the model resolved for it. +// The model ID is always sourced from the resolved ModelConfig (config or +// catalog), which is what makes the reason self-updating. +func describeRouting(trigger string, primary config.ModelConfig) string { + modelID := primary.ModelID + if modelID == "" { + modelID = "(unresolved)" + } + if trigger == "" { + return fmt.Sprintf("resolved model %s", modelID) + } + return fmt.Sprintf("%s -> resolved model %s", trigger, modelID) } // resolveRequestedModel checks if the user-specified model should override @@ -131,6 +151,7 @@ func (r *ModelRouter) resolveRequestedModel(cfg *config.Config, requestedModel s Primary: primary, Fallbacks: fallbacks, Scenario: ScenarioDefault, + Reason: describeRouting(fmt.Sprintf("respect_requested_model honored request for %q", requestedModel), primary), }, true, nil } @@ -234,6 +255,7 @@ func (r *ModelRouter) Route(messages []MessageContent, tokenCount int, requested // Otherwise, use scenario-based routing result := DetectScenario(messages, tokenCount, cfg) scenarioKey := string(result.Scenario) + trigger := fmt.Sprintf("scenario=%s (%s)", result.Scenario, result.Reason) // Get primary model for scenario. When cost-based routing is enabled and // a non-empty catalog is available, prefer the cheapest matching catalog @@ -245,6 +267,7 @@ func (r *ModelRouter) Route(messages []MessageContent, tokenCount int, requested if resolved, err := selector.SelectCheapest(scenarioKey, constraints); err == nil { primary = resolvedModelToConfig(resolved) ok = true + trigger += ", cheapest catalog model" } } @@ -257,6 +280,7 @@ func (r *ModelRouter) Route(messages []MessageContent, tokenCount int, requested if !ok { return RouteResult{}, fmt.Errorf("no default model configured") } + trigger += ", scenario not configured so using \"default\" model" } // Get fallbacks for scenario @@ -273,6 +297,7 @@ func (r *ModelRouter) Route(messages []MessageContent, tokenCount int, requested Primary: primary, Fallbacks: fallbacks, Scenario: result.Scenario, + Reason: describeRouting(trigger, primary), }, nil } @@ -358,6 +383,7 @@ func buildOverrideResult(cfg *config.Config, override config.ModelConfig, fallba Primary: override, Fallbacks: fallbacks, Scenario: ScenarioOverride, + Reason: describeRouting(fmt.Sprintf("matched configured override key %q", fallbackKey), override), } } @@ -455,6 +481,7 @@ func (r *ModelRouter) RouteForStreaming(messages []MessageContent, tokenCount in // Otherwise, use scenario-based routing for streaming result := RouteForStreaming(messages, tokenCount, cfg) scenarioKey := string(result.Scenario) + trigger := fmt.Sprintf("scenario=%s (%s)", result.Scenario, result.Reason) // Get primary model for scenario. When cost-based routing is enabled and // a non-empty catalog is available, prefer the cheapest matching catalog @@ -466,6 +493,7 @@ func (r *ModelRouter) RouteForStreaming(messages []MessageContent, tokenCount in if resolved, err := selector.SelectCheapest(scenarioKey, constraints); err == nil { primary = resolvedModelToConfig(resolved) ok = true + trigger += ", cheapest catalog model" } } if !ok { @@ -477,6 +505,9 @@ func (r *ModelRouter) RouteForStreaming(messages []MessageContent, tokenCount in if !ok { // Fall back to default primary = cfg.Models["default"] + trigger += ", scenario and \"fast\" not configured so using \"default\" model" + } else { + trigger += ", scenario not configured so using \"fast\" model" } } if primary.ModelID == "" { @@ -498,6 +529,7 @@ func (r *ModelRouter) RouteForStreaming(messages []MessageContent, tokenCount in Primary: primary, Fallbacks: fallbacks, Scenario: result.Scenario, + Reason: describeRouting(trigger, primary), }, nil } diff --git a/internal/router/model_router_test.go b/internal/router/model_router_test.go index 78e438ed..d0c9f0fb 100644 --- a/internal/router/model_router_test.go +++ b/internal/router/model_router_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "github.com/routatic/proxy/internal/catalog" @@ -1106,3 +1107,81 @@ func TestListModels_Empty(t *testing.T) { t.Errorf("expected no models, got %+v", models) } } + +// TestRoute_ReasonReportsConfiguredModel proves the routing reason names the +// model actually resolved from config — using deliberately unusual model IDs so +// the assertion can only pass if the value came from config, not a literal in +// the router. +func TestRoute_ReasonReportsConfiguredModel(t *testing.T) { + cfg := &config.Config{ + RespectRequestedModel: boolPtr(false), + Models: map[string]config.ModelConfig{ + "default": {ModelID: "totally-made-up-default-v9"}, + "complex": {ModelID: "totally-made-up-complex-v9"}, + "fast": {ModelID: "totally-made-up-fast-v9"}, + }, + Fallbacks: map[string][]config.ModelConfig{ + "default": {{ModelID: "totally-made-up-default-v9"}}, + }, + } + router := NewModelRouter(newTestAtomicConfig(cfg)) + + complexMessages := []MessageContent{{Role: "user", Content: "Architect a new microservice"}} + result, err := router.Route(complexMessages, 100, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result.Reason, "totally-made-up-complex-v9") { + t.Errorf("expected reason to name the configured complex model, got: %s", result.Reason) + } + // The trigger must survive alongside the model so the log stays debuggable. + if !strings.Contains(result.Reason, "scenario=complex") { + t.Errorf("expected reason to explain the scenario trigger, got: %s", result.Reason) + } + + streamResult, err := router.RouteForStreaming(complexMessages, 100, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(streamResult.Reason, "totally-made-up-fast-v9") { + t.Errorf("expected streaming reason to name the configured fast model, got: %s", streamResult.Reason) + } +} + +// TestRoute_ReasonReportsRequestedModel covers the respect_requested_model path. +func TestRoute_ReasonReportsRequestedModel(t *testing.T) { + cfg := &config.Config{ + RespectRequestedModel: boolPtr(true), + Models: map[string]config.ModelConfig{ + "default": {ModelID: "totally-made-up-default-v9"}, + "my-alias-v9": {ModelID: "totally-made-up-requested-v9"}, + }, + } + router := NewModelRouter(newTestAtomicConfig(cfg)) + + result, err := router.Route([]MessageContent{{Role: "user", Content: "Hello"}}, 100, "my-alias-v9") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result.Reason, "totally-made-up-requested-v9") { + t.Errorf("expected reason to name the resolved requested model, got: %s", result.Reason) + } +} + +// TestRouteWithOverride_ReasonReportsOverrideModel covers the model_overrides path. +func TestRouteWithOverride_ReasonReportsOverrideModel(t *testing.T) { + cfg := &config.Config{ + ModelOverrides: map[string]config.ModelConfig{ + "claude-opus-4-20250514": {ModelID: "totally-made-up-override-v9"}, + }, + } + router := NewModelRouter(newTestAtomicConfig(cfg)) + + result, ok := router.RouteWithOverride("claude-opus-4-20250514") + if !ok { + t.Fatal("expected override to match") + } + if !strings.Contains(result.Reason, "totally-made-up-override-v9") { + t.Errorf("expected reason to name the override model, got: %s", result.Reason) + } +} diff --git a/internal/router/policy.go b/internal/router/policy.go index bd19df00..2003384b 100644 --- a/internal/router/policy.go +++ b/internal/router/policy.go @@ -179,6 +179,6 @@ func (p *ScenarioPolicy) Evaluate(ctx *EvaluationContext) ([]config.ModelConfig, PolicyName: "scenario", ModelID: result.Primary.ModelID, Provider: result.Primary.Provider, - Reason: fmt.Sprintf("scenario=%s: %s", result.Scenario, result.Scenario), + Reason: result.Reason, }, nil } diff --git a/internal/router/scenarios.go b/internal/router/scenarios.go index 324e68c2..e6b9309e 100644 --- a/internal/router/scenarios.go +++ b/internal/router/scenarios.go @@ -24,6 +24,13 @@ const ( ) // ScenarioResult contains the detected scenario and token count. +// +// Reason explains *why* the scenario matched (which threshold was crossed, +// which pattern fired). It deliberately never names a model: scenario +// detection happens before the model for that scenario is resolved from +// config, so any model name here would be a guess that drifts as soon as the +// config changes. The model actually used is appended later by the router — +// see RouteResult.Reason in model_router.go. type ScenarioResult struct { Scenario Scenario TokenCount int @@ -74,7 +81,7 @@ func DetectScenario(messages []MessageContent, tokenCount int, cfg *config.Confi return ScenarioResult{ Scenario: ScenarioLongContext, TokenCount: tokenCount, - Reason: fmt.Sprintf("token count %d exceeds threshold %d (use MiniMax for 1M context)", tokenCount, threshold), + Reason: fmt.Sprintf("token count %d exceeds long-context threshold %d", tokenCount, threshold), } } @@ -99,7 +106,7 @@ func DetectScenario(messages []MessageContent, tokenCount int, cfg *config.Confi return ScenarioResult{ Scenario: ScenarioComplex, TokenCount: tokenCount, - Reason: "complex or tool-based operation detected (use GLM-5.1)", + Reason: "complex or tool-based operation keywords in latest user message", } } @@ -108,7 +115,7 @@ func DetectScenario(messages []MessageContent, tokenCount int, cfg *config.Confi return ScenarioResult{ Scenario: ScenarioThink, TokenCount: tokenCount, - Reason: "thinking/reasoning pattern detected (use GLM-5)", + Reason: "thinking/reasoning keywords in latest user message", } } @@ -117,7 +124,7 @@ func DetectScenario(messages []MessageContent, tokenCount int, cfg *config.Confi return ScenarioResult{ Scenario: ScenarioBackground, TokenCount: tokenCount, - Reason: "simple background task detected (use Qwen3.5 Plus)", + Reason: "simple read-only request with no tool keywords", } } @@ -125,7 +132,7 @@ func DetectScenario(messages []MessageContent, tokenCount int, cfg *config.Confi return ScenarioResult{ Scenario: ScenarioDefault, TokenCount: tokenCount, - Reason: "default scenario (use Kimi K2.6)", + Reason: "no scenario pattern matched, using default scenario", } } @@ -303,8 +310,8 @@ func getLongContextThreshold(cfg *config.Config) int { // This may return a less capable model but one that streams faster. func RouteForStreaming(messages []MessageContent, tokenCount int, cfg *config.Config) ScenarioResult { facts := AnalyzeRequestFacts(messages) - // For streaming, use simpler models that have better TTFT - // Complex models (GLM, Kimi) are too slow for streaming with many tools + // For streaming, prefer the scenarios whose configured models have better + // TTFT; the most capable models are too slow to stream with many tools. threshold := getLongContextThreshold(cfg) if tokenCount > threshold { @@ -315,16 +322,10 @@ func RouteForStreaming(messages []MessageContent, tokenCount int, cfg *config.Co Reason: fmt.Sprintf("high token count image request (%d > %d)", tokenCount, threshold), } } - model := "long_context" - if cfg != nil { - if lc, ok := cfg.Models["long_context"]; ok && lc.ModelID != "" { - model = lc.ModelID - } - } return ScenarioResult{ Scenario: ScenarioLongContext, TokenCount: tokenCount, - Reason: fmt.Sprintf("high token count streaming (%d > %d) - use %s for acceptable TTFT", tokenCount, threshold, model), + Reason: fmt.Sprintf("streaming token count %d exceeds long-context threshold %d", tokenCount, threshold), } } @@ -345,12 +346,13 @@ func RouteForStreaming(messages []MessageContent, tokenCount int, cfg *config.Co latestUser := latestUserMessages(messages) if hasComplexPattern(latestUser) || hasThinkingPattern(latestUser) { - // Complex request but streaming - downgrade to faster model - // GLM-5 and Kimi are too slow for streaming with complex prompts + // Complex request but streaming - downgrade to the fast scenario, whose + // model is resolved from config, because the most capable models are + // too slow for streaming with complex prompts. return ScenarioResult{ Scenario: ScenarioFast, TokenCount: tokenCount, - Reason: "complex request but streaming - use fast model (qwen3.6-plus) for better TTFT", + Reason: "complex/thinking pattern but streaming, downgraded to fast scenario for better TTFT", } } @@ -358,6 +360,6 @@ func RouteForStreaming(messages []MessageContent, tokenCount int, cfg *config.Co return ScenarioResult{ Scenario: ScenarioFast, TokenCount: tokenCount, - Reason: "streaming request - use fast model (qwen3.6-plus)", + Reason: "streaming request with no complex pattern, using fast scenario", } } diff --git a/internal/router/scenarios_test.go b/internal/router/scenarios_test.go index 0f24ff44..bce3ee9f 100644 --- a/internal/router/scenarios_test.go +++ b/internal/router/scenarios_test.go @@ -267,8 +267,14 @@ func TestRouteForStreaming_RespectsConfiguredThreshold(t *testing.T) { if result.Scenario != ScenarioLongContext { t.Errorf("Expected ScenarioLongContext for 300000 tokens with threshold 256000, got %s", result.Scenario) } - if !strings.Contains(result.Reason, "deepseek-v4-flash") { - t.Errorf("Expected reason to mention configured model 'deepseek-v4-flash', got: %s", result.Reason) + // Scenario detection happens before a model is resolved, so the reason + // explains the trigger and names no model. The resolved model is appended + // by the router — see TestRoute_ReasonReportsConfiguredModel. + if !strings.Contains(result.Reason, "256000") { + t.Errorf("Expected reason to mention the configured threshold, got: %s", result.Reason) + } + if strings.Contains(result.Reason, "deepseek-v4-flash") { + t.Errorf("Expected scenario reason not to name a model, got: %s", result.Reason) } } @@ -307,7 +313,48 @@ func TestRouteForStreaming_NilConfig(t *testing.T) { if result.Scenario != ScenarioLongContext { t.Errorf("Expected ScenarioLongContext for 110000 tokens with nil config, got %s", result.Scenario) } - if !strings.Contains(result.Reason, "long_context") { - t.Errorf("Expected reason to contain fallback model name 'long_context', got: %s", result.Reason) + if !strings.Contains(result.Reason, "100000") { + t.Errorf("Expected reason to mention the default threshold, got: %s", result.Reason) + } +} + +// TestDetectScenario_ReasonsNameNoModels guards against reintroducing +// hardcoded model names into scenario reasons, which is how they went stale +// before: the reason must describe the trigger only, since the model for a +// scenario comes from config and is appended by the router. +func TestDetectScenario_ReasonsNameNoModels(t *testing.T) { + cases := []struct { + name string + messages []MessageContent + tokens int + }{ + {"complex", []MessageContent{{Role: "user", Content: "Architect a new service"}}, 100}, + {"think", []MessageContent{{Role: "user", Content: "Think about this"}}, 100}, + {"background", []MessageContent{{Role: "user", Content: "what is Go"}}, 100}, + {"default", []MessageContent{{Role: "user", Content: "Hello"}}, 100}, + {"long_context", []MessageContent{{Role: "user", Content: "Hello"}}, 70000}, + } + + // Substrings from model families this proxy routes to. None of them belong + // in a scenario reason. + banned := []string{"glm", "kimi", "qwen", "minimax", "mimo", "deepseek"} + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for label, reason := range map[string]string{ + "DetectScenario": DetectScenario(tc.messages, tc.tokens, mockConfig()).Reason, + "RouteForStreaming": RouteForStreaming(tc.messages, tc.tokens, mockConfig()).Reason, + } { + if reason == "" { + t.Fatalf("%s: expected a non-empty reason", label) + } + lower := strings.ToLower(reason) + for _, model := range banned { + if strings.Contains(lower, model) { + t.Errorf("%s: reason names model %q (must describe the trigger only): %s", label, model, reason) + } + } + } + }) } } diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index 3c698711..9f75e52f 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -15,8 +15,9 @@ name: routatic-proxy arch: ${NFPM_ARCH} platform: linux version: ${NFPM_VERSION} -# semver schema: a prerelease suffix (-beta.N) is folded into the RPM Release -# field so beta packages sort *below* the matching stable release. +# semver schema: a prerelease suffix (-beta.N) becomes an RPM tilde version, +# e.g. 0.6.4-beta.1 -> Version 0.6.4~beta.1 with Release 1, so beta packages +# sort *below* the matching stable release. version_schema: semver release: "1" maintainer: samuel tuyizere From 9367582c6455a04a5b49676d2d0c0960cc1ca53f Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Fri, 21 Aug 2026 03:37:41 +0200 Subject: [PATCH 3/5] docs: resolve Kimi K2.6 cost contradiction, document RPM and macOS GUI installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Kimi K2.6 row in the cost comparison table said ~1,150 requests per $12 while its own profile, the decision tree and the quick reference all said 1,850. Nothing in the repo can settle it — the model catalog carries no rate data for any of its 353 models — but the row duplicated GLM-5's figure exactly, which reads as a copy-paste, and 1,850 is what the other three places and the model's value rating agree on. Both files now say 1,850, and the table gained a note that these figures are indicative estimates rather than a machine-readable price list, so the next reader knows not to budget against them. INSTALLATION.md documented Homebrew, Scoop, source, raw binaries and Docker but never mentioned the RPMs, even though it is the canonical install doc that README points at. It now covers dnf installation, the noreplace config template, the opt-in systemd user unit, the missing GPG signature, and the fact that `routatic-proxy update` is for standalone binaries rather than RPM installs. The macOS DMG was documented only in Chinese. Ported to English, describing what the menu bar item actually offers per internal/tray/tray_darwin.go — status, Open Console, start/stop, and Start on Boot — with the CLI equivalents. The Chinese install guide gained the RPM section in turn, so the two stay in sync. --- INSTALLATION.md | 37 +++++++++++++++++++++++++++++++++++++ MODELS.md | 8 +++++++- docs/zh/INSTALLATION.md | 20 ++++++++++++++++++++ docs/zh/MODELS.md | 5 ++++- 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/INSTALLATION.md b/INSTALLATION.md index 0c5cff9b..32b040e1 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -53,6 +53,43 @@ Move-Item -Path "routatic-proxy.exe" -Destination "$env:LOCALAPPDATA\Microsoft\W Homebrew and Scoop installs also provide `oc-go-cc` as an alias for `routatic-proxy`. +## Fedora / RHEL (RPM) + +Every release publishes RPMs for `x86_64` and `aarch64`, so `dnf` handles +upgrades and removal for you: + +```bash +VERSION=0.6.3 # pick a version from the Releases page +ARCH=$(uname -m) # x86_64 or aarch64 +sudo dnf install "https://github.com/routatic/proxy/releases/download/v${VERSION}/routatic-proxy-${VERSION}-1.${ARCH}.rpm" +``` + +The package installs the binary to `/usr/bin/routatic-proxy`, a config template +to `/etc/routatic-proxy/config.json` (marked `noreplace`, so upgrades never +overwrite your edits), and an optional systemd **user** unit you can opt into +with `systemctl --user enable --now routatic-proxy`. The RPMs are not GPG-signed +yet — verify against `checksums.txt` on the release page. Note that +`routatic-proxy update` is for standalone binaries; on an RPM install, upgrade +through `dnf` instead. + +See [docs/fedora-setup.md](docs/fedora-setup.md) for the full Fedora guide, +including the systemd and troubleshooting details. + +## macOS GUI (DMG) + +macOS users can install the app bundle instead of the CLI: + +1. Open the [Releases page](https://github.com/routatic/proxy/releases) +2. Download `RoutaticProxy.dmg` from the latest release +3. Open it and drag the app into your Applications folder +4. Launch routatic-proxy from Launchpad or Applications + +The app runs as a menu bar item rather than a window. Its menu shows the proxy's +current status and offers **Open Console...** for the dashboard, **Start Proxy** / +**Stop Proxy**, and a **Start on Boot** toggle. The same functionality is +available from the CLI via `routatic-proxy start`, `stop`, `status`, and +`autostart enable`. + ## Docker ### Pull the prebuilt image diff --git a/MODELS.md b/MODELS.md index b4cec222..d97f2bb8 100644 --- a/MODELS.md +++ b/MODELS.md @@ -8,6 +8,12 @@ Comprehensive guide to OpenCode Go and Zen models with capabilities, costs, and > 💰 **Cost-conscious routing matters!** Qwen3.5 Plus gives you 10,200 requests per $12, while GLM-5.1 gives you only 880 — that's **11.6x fewer requests** for the same budget. +> **Note:** the requests-per-$12 figures below are approximate estimates for +> comparison only — they are not derived from a machine-readable price list, and +> the model catalog carries no rate data. Treat the ordering as meaningful and +> the absolute numbers as indicative. Check your provider's current pricing +> before budgeting. + | Model | Provider | Requests per $12 (5hr) | Cost Efficiency | Quality | | ------------------ | ------------- | ---------------------- | --------------- | ------- | | **Qwen3.5 Plus** | Go | **10,200** | ★★★★★ | ★★☆☆☆ | @@ -19,7 +25,7 @@ Comprehensive guide to OpenCode Go and Zen models with capabilities, costs, and | **MiMo-V2.5** | Go | **2,150** | ★★★☆☆ | ★★★☆☆ | | **MiMo-V2.5-Pro** | Go | **1,290** | ★★☆☆☆ | ★★★★☆ | | **Kimi K2.5** | Go | **1,850** | ★★☆☆☆ | ★★★★☆ | -| **Kimi K2.6** | Go | **~1,150** | ★☆☆☆☆ | ★★★★★ | +| **Kimi K2.6** | Go | **1,850** | ★★☆☆☆ | ★★★★★ | | **Kimi K2.7 Code** | Go | **1,350** | ★☆☆☆☆ | ★★★★★ | | **Kimi K3** | Go | **$3/$15 per 1M** | ☆☆☆☆☆ | ★★★★★ | | **GLM-5** | Go | **1,150** | ★☆☆☆☆ | ★★★★☆ | diff --git a/docs/zh/INSTALLATION.md b/docs/zh/INSTALLATION.md index 657129e7..9e7935c1 100644 --- a/docs/zh/INSTALLATION.md +++ b/docs/zh/INSTALLATION.md @@ -55,6 +55,26 @@ Move-Item -Path "routatic-proxy.exe" -Destination "$env:LOCALAPPDATA\Microsoft\W Homebrew 和 Scoop 安装也提供 `oc-go-cc` 作为 `routatic-proxy` 的别名。 +## Fedora / RHEL(RPM) + +每个发布版本都会提供 `x86_64` 和 `aarch64` 的 RPM 包,升级和卸载都可以交给 `dnf` 处理: + +```bash +VERSION=0.6.3 # 从 Releases 页面选择版本 +ARCH=$(uname -m) # x86_64 或 aarch64 +sudo dnf install "https://github.com/routatic/proxy/releases/download/v${VERSION}/routatic-proxy-${VERSION}-1.${ARCH}.rpm" +``` + +该软件包将二进制文件安装到 `/usr/bin/routatic-proxy`,配置模板安装到 +`/etc/routatic-proxy/config.json`(标记为 `noreplace`,升级时不会覆盖你的修改), +并附带一个可选的 systemd **用户** 单元,可通过 +`systemctl --user enable --now routatic-proxy` 启用。RPM 包目前尚未进行 GPG 签名, +请使用发布页面的 `checksums.txt` 校验。注意:`routatic-proxy update` 适用于独立二进制安装; +使用 RPM 安装时,请通过 `dnf` 升级。 + +完整的 Fedora 指南(含 systemd 与故障排除细节)见 +[docs/fedora-setup.md](../fedora-setup.md)。 + ## Docker ### 拉取预构建镜像 diff --git a/docs/zh/MODELS.md b/docs/zh/MODELS.md index f26664c9..070339af 100644 --- a/docs/zh/MODELS.md +++ b/docs/zh/MODELS.md @@ -10,6 +10,9 @@ OpenCode Go 和 Zen 模型的综合指南,包括能力、成本和路由建议 > 💰 **注重成本的路由很重要!** Qwen3.5 Plus 让你用 $12 获得 10,200 次请求,而 GLM-5.1 只有 880 次 —— 同样的预算少了 **11.6 倍** 的请求。 +> **注意:** 下表中的“每 $12 请求数”为近似估算,仅用于横向比较:它并非来自机器可读的价目表, +> 模型目录中也不包含费率数据。相对排序有意义,绝对数值仅供参考。制定预算前请查询提供商的最新定价。 + | 模型 | 提供商 | 每 $12 请求数 (5小时) | 成本效率 | 质量 | |------|--------|------------------------|----------|------| | **Qwen3.5 Plus** | Go | **10,200** | ★★★★★ | ★★☆☆☆ | @@ -21,7 +24,7 @@ OpenCode Go 和 Zen 模型的综合指南,包括能力、成本和路由建议 | **MiMo-V2.5** | Go | **2,150** | ★★★☆☆ | ★★★☆☆ | | **MiMo-V2.5-Pro** | Go | **1,290** | ★★☆☆☆ | ★★★★☆ | | **Kimi K2.5** | Go | **1,850** | ★★☆☆☆ | ★★★★☆ | -| **Kimi K2.6** | Go | **~1,150** | ★☆☆☆☆ | ★★★★★ | +| **Kimi K2.6** | Go | **1,850** | ★★☆☆☆ | ★★★★★ | | **Kimi K2.7 Code** | Go | **1,350** | ★☆☆☆☆ | ★★★★★ | | **Kimi K3** | Go | **$3/$15 每 1M** | ☆☆☆☆☆ | ★★★★★ | | **GLM-5** | Go | **1,150** | ★☆☆☆☆ | ★★★★☆ | From 82b2e1d6c00f64fcca0fed374972b2cbb945fa26 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Fri, 21 Aug 2026 03:43:53 +0200 Subject: [PATCH 4/5] fix(ci): extract RPM payloads with relative paths in verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RPM Packaging job failed on ubuntu-latest with "cpio: cannot make directory '/etc/routatic-proxy': Permission denied". RPM payload members are absolute paths, and whether cpio strips the leading "/" by default differs between distributions — Fedora's does, Ubuntu's does not, so the extraction step aimed at the runner's real /usr and /etc instead of the temp dir. Passing --no-absolute-filenames makes it explicit rather than relying on the local default. This is why the job exists: the same script passed on a Fedora workstation and only failed on a real Linux CI runner. --- .github/scripts/verify-rpm.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/scripts/verify-rpm.sh b/.github/scripts/verify-rpm.sh index a2e1f606..1db92b28 100755 --- a/.github/scripts/verify-rpm.sh +++ b/.github/scripts/verify-rpm.sh @@ -112,7 +112,11 @@ WORKDIR="$(mktemp -d)" trap "rm -rf '$WORKDIR'" EXIT RPM_ABS="$(cd "$(dirname "$RPM_FILE")" && pwd)/$(basename "$RPM_FILE")" -(cd "$WORKDIR" && rpm2cpio "$RPM_ABS" | cpio -idm --quiet) +# --no-absolute-filenames is required, not cosmetic: RPM payload members are +# absolute paths, and whether cpio strips the leading "/" by default differs +# between distributions. Without it, extraction targets the real /usr and /etc +# and fails on permissions (or, running as root, would overwrite the host). +(cd "$WORKDIR" && rpm2cpio "$RPM_ABS" | cpio -idm --quiet --no-absolute-filenames) BIN="${WORKDIR}/usr/bin/routatic-proxy" if [ ! -f "$BIN" ]; then From bc6a9a3af76930f785614e4e1c5f180ee5cc25b2 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Fri, 21 Aug 2026 17:45:39 +0200 Subject: [PATCH 5/5] fix(ci): judge cpio's status, not rpm2cpio's, when verifying RPMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ubuntu's rpm2cpio exits 1 even when it has written the payload in full, while Fedora's exits 0. Under `set -o pipefail` that sank the whole extraction pipeline, so the RPM job failed on ubuntu-latest immediately after the previous fix let cpio run at all. The status we care about is cpio's, so the pipeline runs with pipefail off and its subshell status is captured instead. Because that means neither exit code is fully trusted, the script now also compares the extracted binary's size against the size recorded in the RPM header — a mid-stream extraction failure would otherwise leave a truncated binary whose ELF header still looks valid. Reproduced and fixed against ubuntu:24.04 in a container rather than guessing: both arches now pass on Ubuntu and Fedora, and the wrong-arch and wrong-version negative controls still exit 1. --- .github/scripts/verify-rpm.sh | 40 ++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/.github/scripts/verify-rpm.sh b/.github/scripts/verify-rpm.sh index 1db92b28..ee304e36 100755 --- a/.github/scripts/verify-rpm.sh +++ b/.github/scripts/verify-rpm.sh @@ -112,11 +112,41 @@ WORKDIR="$(mktemp -d)" trap "rm -rf '$WORKDIR'" EXIT RPM_ABS="$(cd "$(dirname "$RPM_FILE")" && pwd)/$(basename "$RPM_FILE")" -# --no-absolute-filenames is required, not cosmetic: RPM payload members are -# absolute paths, and whether cpio strips the leading "/" by default differs -# between distributions. Without it, extraction targets the real /usr and /etc -# and fails on permissions (or, running as root, would overwrite the host). -(cd "$WORKDIR" && rpm2cpio "$RPM_ABS" | cpio -idm --quiet --no-absolute-filenames) + +# Two portability traps here, both found by running this on Fedora and Ubuntu: +# +# 1. --no-absolute-filenames: RPM payload members are absolute paths, and +# whether cpio strips the leading "/" by default differs between +# distributions. Without it, extraction targets the real /usr and /etc and +# fails on permissions (or, as root, would overwrite the host). +# 2. Ubuntu's rpm2cpio exits 1 even on a fully successful extraction, while +# Fedora's exits 0. Under `set -o pipefail` that sinks the whole pipeline, +# so we judge cpio's status instead of the pipeline's — and then prove the +# payload really is complete by comparing the extracted binary against the +# size RPM recorded for it, rather than trusting either exit code. +# With pipefail off, the subshell's exit status is cpio's — the last command in +# the pipeline — which is the one whose success we actually care about. +CPIO_STATUS=0 +set +o pipefail +(cd "$WORKDIR" && rpm2cpio "$RPM_ABS" | cpio -idm --quiet --no-absolute-filenames) || + CPIO_STATUS=$? +set -o pipefail + +if [ "$CPIO_STATUS" -ne 0 ]; then + fail "cpio failed to extract the RPM payload (exit $CPIO_STATUS)" +fi + +EXPECTED_SIZE="$(rpm -qp --qf '[%{FILENAMES} %{FILESIZES}\n]' "$RPM_FILE" 2>/dev/null | + awk '$1 == "/usr/bin/routatic-proxy" { print $2 }')" +ACTUAL_SIZE="$([ -f "${WORKDIR}/usr/bin/routatic-proxy" ] && + wc -c < "${WORKDIR}/usr/bin/routatic-proxy" | tr -d ' ' || echo 0)" +if [ -z "$EXPECTED_SIZE" ]; then + fail "RPM header records no size for /usr/bin/routatic-proxy" +elif [ "$ACTUAL_SIZE" != "$EXPECTED_SIZE" ]; then + fail "Extracted binary is truncated: got ${ACTUAL_SIZE} bytes, header says ${EXPECTED_SIZE}" +else + echo "payload: extracted binary is complete (${ACTUAL_SIZE} bytes)" +fi BIN="${WORKDIR}/usr/bin/routatic-proxy" if [ ! -f "$BIN" ]; then