diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..41eab7a --- /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/scripts/build-rpms.sh b/.github/scripts/build-rpms.sh new file mode 100755 index 0000000..ff3719a --- /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 0000000..ee304e3 --- /dev/null +++ b/.github/scripts/verify-rpm.sh @@ -0,0 +1,189 @@ +#!/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")" + +# 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 + 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 92df5fe..bf91354 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,6 +206,58 @@ jobs: echo "Built binaries:" ls -lh dist/ + # 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 + + # 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: Stage Windows binaries for signing if: ${{ steps.signing.outputs.enabled == 'true' }} run: | @@ -167,10 +294,33 @@ 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 - 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 +437,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/ci.yml b/.github/workflows/ci.yml index d4b8080..bcc510f 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 3bfefec..f5019cc 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,6 +204,58 @@ jobs: echo "Built binaries:" ls -lh dist/ + # 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 + + # 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: Stage Windows binaries for signing if: ${{ steps.signing.outputs.enabled == 'true' }} run: | @@ -165,10 +292,33 @@ 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 - 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 +439,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 1fb1d34..f21ca2e 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/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..facf85d --- /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 bf13b66..04a833e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,8 @@ 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 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 @@ -38,37 +39,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 +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`). @@ -87,7 +111,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 +120,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 +169,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 +193,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 @@ -192,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 e650b55..ecdcc39 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`) @@ -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 179ebf7..5f238a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,10 +3,24 @@ ## 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/) 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 1. Fork and clone the repository: @@ -43,7 +57,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 @@ -79,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 @@ -112,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/INSTALLATION.md b/INSTALLATION.md index 0c5cff9..32b040e 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 5953fa6..d97f2bb 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** | ★☆☆☆☆ | ★★★★☆ | @@ -349,7 +355,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 +382,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 +469,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 +495,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 +518,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 +530,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 +636,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 +647,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 +655,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 +670,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 +746,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 +760,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 +775,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 +834,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 +858,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 +869,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 8c06f8f..fd61d67 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 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/ @@ -111,3 +124,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 d108473..c70a20e 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). @@ -120,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 b50157e..938cda5 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,27 @@ 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` 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 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 +205,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 +283,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 +491,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/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 7f34a45..461cadd 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/configs/config.example.json b/configs/config.example.json index e515f23..096e1e9 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 69bb44e..616f44f 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 6ac55cf..7368c2a 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/howto-debug-routing.md b/docs/howto-debug-routing.md index 9c62665..bc76d4f 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 8671351..010ea13 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,69 +1,22 @@ # Supported Models -Complete model reference for routatic-proxy including OpenCode Go, Zen, OpenRouter, and deprecated models. - ---- - -## 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 | - -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. - -### 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. - ---- - -## 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/reference-api.md b/docs/reference-api.md index 68649b6..42d9ac0 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 aed2b54..d8ec14a 100644 --- a/docs/zh/CONFIGURATION.md +++ b/docs/zh/CONFIGURATION.md @@ -18,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": { @@ -32,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 } @@ -71,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": { @@ -202,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}` 插值。 @@ -214,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_*` 值优先。 @@ -238,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)。** @@ -254,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` | -路由优先级:**长上下文** > **思考** > **后台** > **默认** +路由优先级:**长上下文** > **视觉** > **复杂** > **思考** > **后台** > **默认** ## 基于成本的路由 @@ -285,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。 + ## 降级链 当模型请求失败(网络错误、速率限制、服务器错误)时,代理尝试降级链中的下一个模型: @@ -384,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 b3d6b69..9e7935c 100644 --- a/docs/zh/INSTALLATION.md +++ b/docs/zh/INSTALLATION.md @@ -55,8 +55,46 @@ 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 +### 拉取预构建镜像 + +预构建的多架构镜像(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 43a44b7..070339a 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** | ★☆☆☆☆ | ★★★★☆ | @@ -50,6 +53,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 自动处理这个问题,但你应该了解: @@ -65,10 +352,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 自动处理所有路由。 @@ -94,9 +381,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 模型 @@ -181,9 +468,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": { // 推理任务 @@ -207,8 +494,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) @@ -230,8 +517,9 @@ DeepSeek V4 Pro 和 Flash 在 Go 和 Zen 提供商上都是 OpenAI 兼容的。D - **模型 ID:** `qwen3.5-plus` - **成本:** **每 $12 10,200 次请求**(最佳性价比!) -- **上下文:** ~128K tokens +- **上下文:** **~1M tokens** - **质量:** ★★☆☆☆(适合简单任务) +- **模态:** 文本和图像输入 - **最适合:** - 文件读取操作 - 目录列表 @@ -241,31 +529,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 ### 平衡模型(质量 + 成本) @@ -343,8 +635,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** - **质量:** ★★★☆☆(对大多数任务足够好) +- **模态:** 文本和图像输入 - **速度:** 快 - **最适合:** - 通用编码(默认选择) @@ -353,31 +646,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 —— 质量 + 合理成本(前代) @@ -385,6 +669,7 @@ DeepSeek V4 Pro 和 Flash 在 Go 和 Zen 提供商上都是 OpenAI 兼容的。D - **成本:** **每 $12 1,850 次请求** - **上下文:** ~256K tokens(是大多数模型的两倍) - **质量:** ★★★★☆(优秀) +- **模态:** 文本和图像输入 - **速度:** 快 - **最适合:** - 复杂编码任务 @@ -393,20 +678,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 —— 推理专家 @@ -450,6 +721,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` @@ -457,13 +745,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 限制: @@ -515,7 +833,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" }], @@ -533,7 +857,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 | @@ -544,12 +868,12 @@ 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) 监控使用量** ## 另请参阅 - [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/internal/config/atomic.go b/internal/config/atomic.go index f90f6d1..74d5ddd 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 88a5809..84925c9 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 403f794..54edf4a 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/internal/handlers/messages.go b/internal/handlers/messages.go index 02da4b1..9c5f515 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 34cb961..26cffd3 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 78e438e..d0c9f0f 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 bd19df0..2003384 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 324e68c..e6b9309 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 0f24ff4..bce3ee9 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/package-lock.json b/package-lock.json index 2a3c2fe..2d11a02 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 0000000..9f75e52 --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,87 @@ +# 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) 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 +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 0000000..83355ee --- /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