diff --git a/.github/scripts/get-versions.sh b/.github/scripts/get-versions.sh
new file mode 100755
index 00000000..80616f8a
--- /dev/null
+++ b/.github/scripts/get-versions.sh
@@ -0,0 +1,95 @@
+#!/bin/sh
+# get-versions.sh - Detects versions for dual release channel system
+#
+# This script:
+# 1. Gets the latest production version from releases branch tags
+# 2. Generates a beta version with timestamp (industry standard: base on upcoming version + timestamp)
+# 3. Outputs both versions as JSON
+#
+# Version Format:
+# - Beta: v{UPCOMING_VERSION}-beta.{YYYYMMDD.HHMMSS}
+# - Example: v1.3.0-beta.20260712.143015
+# - The timestamp ensures uniqueness even with multiple beta releases in a day
+
+set -eu
+
+# Configuration
+RELEASES_BRANCH="origin/releases"
+TAG_PATTERN="v[0-9]*"
+
+# Default fallback version (used when no tags found)
+DEFAULT_VERSION="v0.0.0"
+
+# Get current UTC timestamp for beta version
+# Format: YYYYMMDD.HHMMSS (dot separator for SemVer compatibility)
+get_timestamp() {
+ date -u +"%Y%m%d.%H%M%S"
+}
+
+# Get the latest production version from releases branch
+get_prod_version() {
+ # Fetch tags from the releases branch
+ git fetch "${RELEASES_BRANCH}" 2>/dev/null || true
+
+ # Get the latest tag matching the pattern
+ # Sort versions in descending order and take the first one
+ latest_tag=$(git tag -l "${TAG_PATTERN}" --sort=-version:refname | head -1)
+
+ # If no tags found, use default
+ if [ -z "${latest_tag}" ]; then
+ echo "${DEFAULT_VERSION}"
+ else
+ echo "${latest_tag}"
+ fi
+}
+
+# Increment minor version for beta (e.g., v1.2.3 → v1.3.0)
+increment_minor_version() {
+ local prod_version="$1"
+ # Parse version components using sed (POSIX compliant)
+ major=$(echo "${prod_version}" | sed 's/^v\([0-9]*\)\..*/\1/')
+ minor=$(echo "${prod_version}" | sed 's/^v[0-9]*\.\([0-9]*\).*/\1/')
+
+ if [ -z "${major}" ] || [ -z "${minor}" ]; then
+ echo "${DEFAULT_VERSION}"
+ return
+ fi
+
+ # Increment minor version, reset patch to 0
+ new_minor=$((minor + 1))
+ echo "v${major}.${new_minor}.0"
+}
+
+# Generate beta version based on upcoming version + timestamp
+# Industry standard: v{UPCOMING_VERSION}-beta.{TIMESTAMP}
+# Example: v1.3.0-beta.20260712.143015
+generate_beta_version() {
+ upcoming_version=$(increment_minor_version "$1")
+ timestamp="$2"
+ echo "${upcoming_version}-beta.${timestamp}"
+}
+
+# Output JSON
+output_json() {
+ prod_version="$1"
+ upcoming_version=$(increment_minor_version "$prod_version")
+ beta_version="$2"
+
+ printf '{
+ "prod_version": "%s",
+ "upcoming_version": "%s",
+ "beta_version": "%s"
+}
+' "${prod_version}" "${upcoming_version}" "${beta_version}"
+}
+
+# Main execution
+main() {
+ prod_version=$(get_prod_version)
+ timestamp=$(get_timestamp)
+ beta_version=$(generate_beta_version "${prod_version}" "${timestamp}")
+
+ output_json "${prod_version}" "${beta_version}"
+}
+
+main
diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml
new file mode 100644
index 00000000..e85edd5c
--- /dev/null
+++ b/.github/workflows/beta-release.yml
@@ -0,0 +1,270 @@
+name: Beta Release
+
+on:
+ push:
+ branches: [main]
+
+permissions:
+ contents: write
+
+jobs:
+ # ── Stage 1: Test & Build Validation ──────────────────────────────
+ validate:
+ name: Test & Build
+ 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"
+
+ - name: Download dependencies
+ run: go mod download
+
+ - name: Vet
+ run: go vet ./...
+
+ - name: Test
+ run: go test ./... -v -race
+
+ - name: Build (sanity check)
+ run: go build -o /dev/null ./cmd/routatic-proxy
+
+ # ── Stage 2: Build & Release (macOS runner for DMG support) ──────
+ release:
+ name: Build & Release
+ needs: validate
+ runs-on: macos-latest
+ outputs:
+ beta_tag: ${{ steps.version.outputs.beta_tag }}
+ prod_version: ${{ steps.version.outputs.prod_version }}
+ 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: |
+ # Make script executable and run it
+ chmod +x .github/scripts/get-versions.sh
+
+ # Run script and capture JSON output
+ VERSIONS_JSON=$(.github/scripts/get-versions.sh)
+
+ # Extract values using jq
+ PROD_VERSION=$(echo "$VERSIONS_JSON" | jq -r '.prod_version')
+ BETA_VERSION=$(echo "$VERSIONS_JSON" | jq -r '.beta_version')
+
+ echo "prod_version=${PROD_VERSION}" >> "$GITHUB_OUTPUT"
+ echo "beta_tag=${BETA_VERSION}" >> "$GITHUB_OUTPUT"
+ echo "version=${BETA_VERSION#v}" >> "$GITHUB_OUTPUT"
+
+ echo "Production version: ${PROD_VERSION}"
+ echo "Beta tag: ${BETA_VERSION}"
+
+ - name: Build cross-platform binaries
+ env:
+ VERSION: ${{ steps.version.outputs.version }}
+ run: |
+ mkdir -p dist
+
+ LDFLAGS="-X main.version=${VERSION}"
+
+ # Define target platforms (bash 3 compatible — macOS default)
+ TARGETS="darwin-amd64:darwin/amd64 darwin-arm64:darwin/arm64 linux-amd64:linux/amd64 linux-arm64:linux/arm64 windows-amd64:windows/amd64 windows-arm64:windows/arm64"
+
+ for TARGET in $TARGETS; do
+ NAME="${TARGET%%:*}"
+ PAIR="${TARGET##*:}"
+ GOOS="${PAIR%/*}"
+ GOARCH="${PAIR#*/}"
+ EXT=""
+ [ "$GOOS" = "windows" ] && EXT=".exe"
+
+ echo "Building ${NAME}..."
+ CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \
+ go build -ldflags "$LDFLAGS -s -w" \
+ -o "dist/routatic-proxy_${NAME}${EXT}" \
+ ./cmd/routatic-proxy
+ done
+
+ echo ""
+ echo "Built binaries:"
+ ls -lh dist/
+
+ - name: Generate checksums
+ run: |
+ cd dist
+ sha256sum routatic-proxy_* > checksums.txt
+ cat checksums.txt
+
+ - name: Build CGO-enabled binary (for DMG)
+ env:
+ VERSION: ${{ steps.version.outputs.version }}
+ run: |
+ CGO_ENABLED=1 go build -tags darwin -ldflags "-X main.version=${VERSION}" -o bin/routatic-proxy ./cmd/routatic-proxy
+
+ - name: Build macOS DMG
+ env:
+ VERSION: ${{ steps.version.outputs.beta_tag }}
+ run: |
+ brew install create-dmg
+ make dmg VERSION=${{ env.VERSION }}
+
+ - name: Generate AI Changelog
+ id: changelog
+ env:
+ OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
+ OPENROUTER_MODEL: ${{ secrets.OPENROUTER_MODEL }}
+ OPENROUTER_TEMPERATURE: ${{ secrets.OPENROUTER_TEMPERATURE }}
+ OPENROUTER_MAX_TOKENS: ${{ secrets.OPENROUTER_MAX_TOKENS }}
+ run: |
+ brew install jq
+
+ # Configuration with defaults
+ MODEL="${OPENROUTER_MODEL:-openai/gpt-4o-mini}"
+ TEMP="${OPENROUTER_TEMPERATURE:-0.3}"
+ TOKENS="${OPENROUTER_MAX_TOKENS:-2000}"
+
+ echo "Using OpenRouter model: $MODEL"
+
+ # Generate changelog for commits since previous beta tag or prod tag
+ PREVIOUS_TAG=$(git describe --tags --abbrev=0 --match "v*-*" 2>/dev/null || git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "")
+
+ if [ -n "$PREVIOUS_TAG" ]; then
+ echo "Generating AI changelog from $PREVIOUS_TAG to ${{ steps.version.outputs.beta_tag }}"
+
+ COMMITS=$(git log "${PREVIOUS_TAG}..HEAD" --pretty=format:"%H%n%s%n%b%n---COMMIT_SEP---")
+ FILE_CHANGES=$(git diff --stat "${PREVIOUS_TAG}..HEAD")
+
+ PROMPT="You are a technical writer generating release notes for a Go proxy server project (routatic-proxy).
+
+ Analyze the provided git commits and file changes, then generate a well-structured
+ changelog in Markdown format. Follow these guidelines:
+
+ 1. Start with a brief summary paragraph (2-3 sentences) describing the overall theme of this beta release
+ 2. Group changes into sections with these exact headers:
+ - **New Features** - New capabilities, enhancements, additions
+ - **Bug Fixes** - Fixes for reported or discovered issues
+ - **Improvements** - Performance, reliability, or code quality improvements
+ - **Documentation** - README, docs, comments, config updates
+ - **Chores** - Build, CI/CD, dependency updates, refactoring
+ 3. Each bullet should be concise but descriptive (one line)
+ 4. Mention breaking changes prominently with a warning emoji Breaking Changes section at the top
+ 5. Use present tense, imperative mood (e.g., Add feature not Added feature)
+ 6. Keep it technical but accessible - target audience is developers using this tool
+ 7. This is a beta release - include a note at the end about testing and feedback
+
+ Format output as clean Markdown without any preamble or explanation. Do not wrap the entire response in a code block."
+
+ USER_CONTENT="Git commits since ${PREVIOUS_TAG}:\n\n${COMMITS}\n\nFiles changed:\n\n${FILE_CHANGES}"
+
+ # Truncate if too long (~4 chars per token)
+ MAX_CHARS=12000
+ if [ "${#USER_CONTENT}" -gt "$MAX_CHARS" ]; then
+ USER_CONTENT="${USER_CONTENT:0:MAX_CHARS}\n\n[...truncated for length...]"
+ fi
+
+ RESPONSE=$(curl -s -L -X POST "https://openrouter.ai/api/v1/chat/completions" \
+ -H "Authorization: Bearer ${OPENROUTER_API_KEY}" \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n \
+ --arg prompt "$PROMPT" \
+ --arg user "$USER_CONTENT" \
+ --arg model "$MODEL" \
+ --arg temp "$TEMP" \
+ --arg tokens "$TOKENS" \
+ '{
+ model: $model,
+ messages: [
+ {role: "system", content: $prompt},
+ {role: "user", content: $user}
+ ],
+ temperature: ($temp | tonumber),
+ max_tokens: ($tokens | tonumber)
+ }' \
+ )" \
+ )
+
+ CHANGELOG=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // empty')
+
+ if [ -z "$CHANGELOG" ] || [ "$CHANGELOG" = "null" ]; then
+ echo "Warning: AI changelog generation failed, using commit list instead" >&2
+ echo "API response: $RESPONSE" >&2
+ CHANGELOG="## Commits\n\n$(git log \"${PREVIOUS_TAG}..HEAD\" --pretty=format:'- %s')"
+ fi
+ else
+ echo "No previous tag found, using commit list"
+ CHANGELOG="## Commits\n\n$(git log --pretty=format:'- %s' -20)"
+ fi
+
+ echo "$CHANGELOG" > changelog.md
+ cat changelog.md
+
+ # GITHUB_TOKEN works here — same repo
+ - name: Create GitHub Prerelease
+ uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
+ with:
+ tag_name: ${{ steps.version.outputs.beta_tag }}
+ name: Beta Release ${{ steps.version.outputs.beta_tag }}
+ body_path: changelog.md
+ draft: false
+ prerelease: true
+ files: |
+ dist/routatic-proxy_*
+ dist/checksums.txt
+ bin/RoutaticProxy.dmg
+
+ # ── Stage 3: Publish Docker Image ───────────────────────────────
+ docker:
+ name: Publish Docker Image
+ needs: release
+ permissions:
+ contents: read
+ packages: write
+ if: github.repository == 'routatic/proxy'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+
+ - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
+
+ - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+
+ - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
+ id: meta
+ with:
+ images: ghcr.io/${{ github.repository }}
+ tags: |
+ type=raw,value=${{ needs.release.outputs.beta_tag }}
+ type=raw,value=beta-${{ needs.release.outputs.prod_version }}
+
+ - name: Strip v prefix from version
+ id: version
+ env:
+ TAG: ${{ needs.release.outputs.beta_tag }}
+ run: echo "value=${TAG#v}" >> "$GITHUB_OUTPUT"
+
+ - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ build-args: VERSION=${{ steps.version.outputs.value }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 560a3a60..6d105a4e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,9 +2,13 @@ name: Release
on:
push:
- branches: [main]
- tags:
- - "v*"
+ branches: [releases]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version to release (e.g., v1.2.3)'
+ required: true
+ type: string
permissions:
contents: write
@@ -15,9 +19,11 @@ jobs:
name: Test & Build
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ - uses: actions/checkout@v4
- - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 - uses: actions/setup-go@v5
+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
+ - uses: actions/setup-go@v5
with:
go-version: "1.25"
@@ -41,11 +47,13 @@ jobs:
outputs:
tag: ${{ steps.version.outputs.tag }}
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ - uses: actions/checkout@v4
with:
fetch-depth: 0
- - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 - uses: actions/setup-go@v5
+ - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
+ - uses: actions/setup-go@v5
with:
go-version: "1.25"
cache: true
@@ -53,26 +61,21 @@ jobs:
- name: Determine version
id: version
run: |
- # Check if there's a tag on this commit
- EXACT_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "")
- if [ -n "$EXACT_TAG" ]; then
- TAG="$EXACT_TAG"
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ # Use user-provided version from workflow_dispatch
+ TAG="${{ github.event.inputs.version }}"
else
- # Auto-increment with rollover (0-9 per segment)
- LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
- BASE=${LATEST_TAG#v}
- IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE"
- PATCH=$((PATCH + 1))
- if [ "$PATCH" -gt 9 ]; then
- PATCH=0
- MINOR=$((MINOR + 1))
- if [ "$MINOR" -gt 9 ]; then
- MINOR=0
- MAJOR=$((MAJOR + 1))
- fi
- fi
- TAG="v${MAJOR}.${MINOR}.${PATCH}"
+ # Push to releases branch - require version to be specified via workflow_dispatch
+ echo "Error: Pushes to releases branch must be done via workflow_dispatch with a version specified"
+ exit 1
+ fi
+
+ # Validate version format (must start with 'v' followed by semver)
+ 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 "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
echo "Releasing as $TAG"
@@ -217,7 +220,8 @@ jobs:
# GITHUB_TOKEN works here — same repo
- name: Create GitHub Release
- uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
+ uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Release ${{ steps.version.outputs.tag }}
@@ -239,19 +243,25 @@ jobs:
if: github.repository == 'routatic/proxy'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ with:
+ fetch-depth: 0
- - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - uses: docker/setup-qemu-action@v3
+ - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
+ - uses: docker/setup-qemu-action@v3
- - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - uses: docker/setup-buildx-action@v3
+ - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+ - uses: docker/setup-buildx-action@v3
- - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 - uses: docker/login-action@v3
+ - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
+ - uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 - uses: docker/metadata-action@v5
+ - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
+ - uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
@@ -267,7 +277,8 @@ jobs:
TAG: ${{ needs.release.outputs.tag }}
run: echo "value=${TAG#v}" >> "$GITHUB_OUTPUT"
- - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 - uses: docker/build-push-action@v6
+ - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
+ - uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -285,7 +296,8 @@ jobs:
if: github.repository == 'routatic/proxy'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ - uses: actions/checkout@v4
- name: Download release assets
env:
@@ -417,7 +429,8 @@ jobs:
if: github.repository == 'routatic/proxy'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@v4
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ - uses: actions/checkout@v4
- name: Download release assets
env:
diff --git a/.gitignore b/.gitignore
index ae5f686d..46662f18 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,6 @@ brag-output-**
.claude
router.test
+.playwright-mcp
+.gstack/
+node_modules/
diff --git a/CLAUDE.md b/CLAUDE.md
index 7fed145f..67d7bbc8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,24 +13,22 @@ make clean # Remove build artifacts
make install # Build and install to $GOPATH/bin
make dist # Cross-compile for all platforms
-## Build with tray support (Linux/macOS)
-# Linux: sudo dnf install libappindicator-gtk3-devel # Fedora/RHEL
-# Linux: sudo apt install libayatana-appindicator3-dev # Ubuntu/Debian
-CGO_ENABLED=1 make build
+# Start proxy with dashboard (recommended)
+./bin/routatic-proxy start
-## The 'ui' command opens the GUI dashboard
-./bin/routatic-proxy ui # Browser-based on Linux, native window on macOS
+# Start proxy only (headless)
+./bin/routatic-proxy serve
```
-### Platform-specific notes
+### Architecture
-**Linux:** The default build uses `CGO_ENABLED=0` and opens the GUI in your default browser via `xdg-open`. For system tray support, build with `CGO_ENABLED=1` after installing the `libappindicator-gtk3-devel` (Fedora/RHEL) or `libayatana-appindicator3-dev` (Ubuntu/Debian) package.
+**routatic-proxy start** runs both the proxy server and GUI dashboard:
+- Proxy listens on `127.0.0.1:3456` (configurable)
+- Dashboard at `http://127.0.0.1:3445`
+- Usage data persists to SQLite (`~/.local/share/routatic-proxy/data.db`) regardless of dashboard state
+- Press Ctrl+C to stop both servers
-**macOS:** The `ui` command opens a native window with system tray integration. Requires CGO with Cocoa headers. For builds without CGO, use `CGO_ENABLED=0 make build` which opens the browser-based GUI.
-
-**Windows:** The `ui` command is not supported. Use CLI only or run the proxy with `make run`.
-
-Run a single test: `go test ./internal/router/ -v`
+**routatic-proxy serve** runs headless (no dashboard).
## Architecture
@@ -112,16 +110,15 @@ Precedence: `*_API_KEYS` → `*_API_KEY` → global `API_KEYS` → global `API_K
## Key Files
- `cmd/routatic-proxy/main.go` — CLI entry point (cobra). Default config template is generated here.
-- `cmd/routatic-proxy/ui_darwin.go` — macOS GUI entry point (`routatic-proxy ui`), webview + tray integration (darwin-only build tag).
- `internal/config/` — Config types and JSON loader with `${VAR}` env interpolation.
- `internal/transformer/` — Request/response format conversion (Anthropic ↔ OpenAI).
- `internal/router/fallback.go` — Circuit breaker per model (3 failures = 30s skip).
- `configs/config.example.json` — Reference config with all options documented.
-- `internal/gui/` — Embedded HTTP server for the webview dashboard (serves static assets + API endpoints).
-- `internal/gui/assets/` — HTML/CSS/JS for the dashboard (Overview, History, Settings tabs).
-- `internal/tray/` — macOS system tray icon and menu (darwin-only build tag).
+- `internal/gui/` — Embedded HTTP server for the dashboard (serves static assets + API endpoints).
+- `internal/gui/assets/` — HTML/CSS/JS for the dashboard (Overview, History, Analytics, Settings tabs).
- `internal/history/` — In-memory ring buffer (1000 entries, O(1) insert, thread-safe).
- `internal/metrics/` — In-process request counters (received, streamed, success, failed, model distribution).
+- `internal/storage/` — SQLite persistence layer for request history, latency samples, and analytics.
### GUI Config Editing
@@ -136,6 +133,68 @@ The Settings tab exposes all config fields as editable form inputs. On save, onl
**Nil safety:** The `/api/metrics` and `/api/history` handlers handle nil dependencies gracefully — they return zero values instead of panicking if the history or metrics instance is unavailable.
+## Dual Release Channel System
+
+This project uses a dual release channel system for separating beta and production releases:
+
+### Beta Channel (Automatic)
+- **Trigger:** Every push to `main` branch (see `.github/workflows/beta-release.yml`)
+- **Version format:** `v{UPCOMING}.beta.{YYYYMMDD.HHMMSS}` (e.g., `v1.3.0-beta.20260712.143015`)
+- **GitHub release:** Marked as `prerelease: true`
+- **Docker tags:** `v{UPCOMING}.beta.{YYYYMMDD.HHMMSS}` and `beta-{UPCOMING}`
+
+Beta releases are fully automated and include:
+- Test suite validation
+- Cross-platform binary builds (darwin-amd64/arm64, linux-amd64/arm64, windows-amd64/arm64)
+- macOS DMG with CGO-enabled binary
+- AI-generated changelog from commits
+- Docker images for linux/amd64 and linux/arm64
+
+### Production Channel (Manual)
+- **Trigger:** Manual `workflow_dispatch` on `releases` branch (see `.github/workflows/release.yml`)
+- **Version format:** `vX.Y.Z` (semantic versioning)
+- **GitHub release:** Marked as `prerelease: false` (stable)
+- **Docker tags:** `vX.Y.Z`, `vX.Y`, `vX`, `latest`
+
+Production releases include all beta features plus:
+- Homebrew tap update (requires `HOMEBREW_PAT` secret)
+- Scoop bucket update (requires `SCOOP_PAT` secret)
+
+### 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., `v1.2.3`)
+2. Increment to the next version (e.g., `v1.3.0`) - **beta is based on upcoming release**
+3. Generate beta version by appending `.beta.{YYYYMMDD.HHMMSS}` - **timestamp ensures uniqueness even with multiple releases per day**
+4. Output both versions as JSON for CI consumption
+
+
+**Version Format Explanation:**
+- `v1.3.0` = The upcoming production version (minor incremented from latest production)
+- `beta.20260712.143015` = Timestamp-based prerelease tag
+- Full example: `v1.3.0-beta.20260712.143015`
+
+### Creating a Production Release
+
+1. Merge all changes to `main` and verify via beta
+2. Ensure `releases` branch exists and is up-to-date
+3. Go to GitHub Actions → Release workflow
+4. Click "Run workflow"
+5. Enter version (must follow `vX.Y.Z` format)
+6. Workflow validates, builds, and releases
+
+### Release Workflow Stages
+
+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
+
+Production adds:
+4. **homebrew** — Update the homebrew-tap formula
+5. **scoop** — Update the scoop-bucket manifest
+
## Skill routing
When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
diff --git a/CONFIGURATION.md b/CONFIGURATION.md
index 00400ef0..1fc1bef1 100644
--- a/CONFIGURATION.md
+++ b/CONFIGURATION.md
@@ -211,6 +211,218 @@ routatic-proxy supports three providers for upstream API calls:
Set `wire_format: "anthropic"` for models that need raw Anthropic Messages format (e.g., Claude on Bedrock). Requires `anthropic_base_url` to be configured.
+### OpenRouter (`openrouter`)
+
+- Unified API for accessing 200+ models from multiple providers (OpenAI, Anthropic, Google, Meta, Mistral, and more)
+- Uses OpenAI Chat Completions API format
+- Pay-as-you-go pricing with competitive rates
+- Set `"provider": "openrouter"` in your model config to use OpenRouter
+
+#### Configuration Schema
+
+```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
+ }
+}
+```
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `name` | `string` | No | Provider display name (defaults to "openrouter") |
+| `base_url` | `string` | No | API endpoint base URL. Default: `https://openrouter.ai/api/v1` |
+| `api_key` | `string` | Yes* | Single API key for authentication. Required if `api_keys` not set |
+| `api_keys` | `string[]` | Yes* | Multiple API keys for round-robin rotation. Required if `api_key` not set |
+| `enabled` | `bool` | No | Whether this provider is active. Default: `true` |
+| `timeout_ms` | `int` | No | Request timeout in milliseconds. Default: `300000` (5 minutes) |
+| `stream_timeout_ms` | `int` | No | Per-chunk timeout during streaming. Default: `60000` (1 minute) |
+
+*At least one of `api_key` or `api_keys` must be configured.
+
+#### Environment Variable Overrides
+
+| Variable | Description | Precedence |
+|----------|-------------|------------|
+| `ROUTATIC_PROXY_OPENROUTER_API_KEY` | Single API key override | Highest |
+| `ROUTATIC_PROXY_OPENROUTER_API_KEYS` | Comma-separated keys for round-robin | Highest |
+| `ROUTATIC_PROXY_OPENROUTER_BASE_URL` | Custom base URL override | Highest |
+
+Environment variables take precedence over config file values. Config values support `${VAR}` interpolation.
+
+Precedence order: `*_API_KEYS` → `*_API_KEY` → config file `api_keys` → config file `api_key`
+
+#### Example Configurations
+
+**Single-key setup:**
+
+```json
+{
+ "openrouter": {
+ "api_key": "sk-or-v1-xxxxxxxxxxxxxxxxxxxxxxxx"
+ }
+}
+```
+
+**Multi-key round-robin for load balancing:**
+
+```json
+{
+ "openrouter": {
+ "api_keys": [
+ "sk-or-v1-key-1",
+ "sk-or-v1-key-2",
+ "sk-or-v1-key-3"
+ ]
+ }
+}
+```
+
+**Custom base URL (for enterprise/self-hosted):**
+
+```json
+{
+ "openrouter": {
+ "base_url": "https://openrouter.mycompany.com/api/v1",
+ "api_key": "${OPENROUTER_API_KEY}",
+ "enabled": true
+ }
+}
+```
+
+#### Integration with Cost-Based Routing
+
+OpenRouter works seamlessly with `cost_routing`. Use `penalty_per_provider` to adjust effective costs:
+
+```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
+ }
+ }
+}
+```
+
+Penalties are additive to the raw model cost. Example: a model costing $0.10/1M tokens on OpenRouter with a 0.02 penalty has effective cost $0.12/1M tokens. Use this to bias routing preferences without excluding providers entirely.
+
+#### Model Resolution via Catalog
+
+Models are referenced using the `provider/model-name` pattern. OpenRouter models use the `openrouter/` prefix:
+
+```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
+ }
+ }
+}
+```
+
+**Discovering models:**
+
+1. Visit [openrouter.ai/models](https://openrouter.ai/models) for the complete model list
+2. Use the `routatic-proxy models` command to see cached catalog entries
+3. Check the [OpenRouter API docs](https://openrouter.ai/docs) for pricing and context limits
+
+The `model_id` in your config must match OpenRouter's model identifier exactly (e.g., `anthropic/claude-opus-4`, `openai/gpt-4o`, `google/gemini-2.5-pro-preview-07-11`).
+
+#### Use Cases
+
+**Accessing specific models:** Use OpenRouter when you need models not available on other providers:
+
+```json
+{
+ "models": {
+ "complex": {
+ "provider": "openrouter",
+ "model_id": "anthropic/claude-opus-4",
+ "temperature": 0.7,
+ "max_tokens": 8192,
+ "reasoning_effort": "max"
+ }
+ }
+}
+```
+
+**Fallback chains:** Include OpenRouter as a fallback when primary providers fail:
+
+```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 optimization:** Use `cost_routing` with provider penalties to automatically select the cheapest available model:
+
+```json
+{
+ "cost_routing": {
+ "enabled": true,
+ "prefer_providers": ["openrouter"],
+ "penalty_per_provider": {
+ "openrouter": -0.01
+ }
+ }
+}
+```
+
+**Specialized models:** Access niche models for specific tasks:
+
+```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
+ }
+ }
+}
+```
+
## Environment Variables
Environment variables override config file values. Config values also support `${VAR}` interpolation.
@@ -223,6 +435,8 @@ Environment variables override config file values. Config values also support `$
| `ROUTATIC_PROXY_PORT` | Proxy listen port | `3456` |
| `ROUTATIC_PROXY_OPENCODE_URL` | OpenCode Go API endpoint | `https://opencode.ai/zen/go/v1/chat/completions` |
| `ROUTATIC_PROXY_OPENCODE_ZEN_URL` | OpenCode Zen API endpoint | `https://opencode.ai/zen/v1/chat/completions` |
+| `ROUTATIC_PROXY_OPENROUTER_API_KEY` | OpenRouter single API key | — |
+| `ROUTATIC_PROXY_OPENROUTER_API_KEYS` | OpenRouter key pool (comma-separated) | — |
| `ROUTATIC_PROXY_LOG_LEVEL` | Log level: `debug`, `info`, `warn`, `error` | `info` |
Legacy equivalents such as `OC_GO_CC_API_KEY`, `OC_GO_CC_CONFIG`, and `OC_GO_CC_PORT` continue to work. When both names are set, the `ROUTATIC_PROXY_*` value wins.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ac6cf024..179ebf79 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -38,6 +38,36 @@
4. Push to your fork and open a pull request against `main`
5. Describe what your PR does and link any related issues
+### Beta Releases
+
+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)
+- **GitHub Release:** Marked as prerelease
+- **Testing:** Download and test before reporting issues
+
+Beta releases allow users to test new features immediately while maintaining a separate stable release channel.
+
+### Production Releases
+
+Production releases are manual and require careful testing:
+
+1. Ensure all changes are merged to `main` and tested via beta
+2. Update the `releases` branch from `main`: `git checkout releases && git merge main`
+3. Push to `releases` branch
+4. Go to GitHub Actions → Release workflow
+5. Click "Run workflow" and specify version (e.g., `v1.2.3`)
+6. The workflow will:
+ - Run full test suite
+ - Build cross-platform binaries
+ - Generate AI-powered changelog
+ - Create GitHub release
+ - Publish Docker images
+ - Update Homebrew tap and Scoop bucket
+
+See [README.md](README.md#release-channels) for more details on the dual release channel system.
+
### Pre-push Hooks
This repository uses git hooks to ensure code quality. Install them once after cloning:
diff --git a/Dockerfile b/Dockerfile
index 535b98a7..9cc16120 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -27,4 +27,4 @@ EXPOSE 3456
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3456/health || exit 1
-ENTRYPOINT ["routatic-proxy", "serve"]
+ENTRYPOINT ["routatic-proxy", "start", "--headless"]
diff --git a/MODELS.md b/MODELS.md
index ff2c4ad8..576f127a 100644
--- a/MODELS.md
+++ b/MODELS.md
@@ -47,6 +47,290 @@ Comprehensive guide to OpenCode Go and Zen models with capabilities, costs, and
- Set `wire_format: "anthropic"` for Claude and other Anthropic-native models
- Best for: Models deployed on your own AWS infrastructure
+## OpenRouter Models
+
+OpenRouter provides **unified access to 100+ models** from multiple providers through a single API endpoint. Instead of managing separate API keys and endpoints for each provider, you can access models from OpenAI, Anthropic, Google, Meta, Mistral, and many others through one integration.
+
+### Key Benefits
+
+| Benefit | Description |
+|---------|-------------|
+| **Unified API** | Single OpenAI-compatible Chat Completions endpoint for all models |
+| **100+ Models** | Access to models from 20+ providers without separate integrations |
+| **Dynamic Catalog** | New models automatically available when the catalog is updated |
+| **Pay-as-you-go** | Per-token pricing with no subscription required |
+| **Smart Routing** | Automatic fallback to alternative providers if a model is unavailable |
+| **Cost Optimization** | Route to the cheapest or fastest available provider |
+
+### Model Naming Convention
+
+OpenRouter uses a `provider/model-name` format that identifies both the original provider and the specific model:
+
+| Format | Example | Description |
+|--------|---------|-------------|
+| `openai/gpt-4o` | `openai/gpt-4o` | GPT-4o via OpenAI |
+| `openai/gpt-4o-mini` | `openai/gpt-4o-mini` | GPT-4o Mini via OpenAI |
+| `anthropic/claude-3.5-sonnet` | `anthropic/claude-3.5-sonnet` | Claude 3.5 Sonnet via Anthropic |
+| `anthropic/claude-3-opus` | `anthropic/claude-3-opus` | Claude 3 Opus via Anthropic |
+| `google/gemini-2.5-pro` | `google/gemini-2.5-pro` | Gemini 2.5 Pro via Google |
+| `google/gemini-2.0-flash` | `google/gemini-2.0-flash` | Gemini 2.0 Flash via Google |
+| `meta-llama/llama-3.3-70b` | `meta-llama/llama-3.3-70b` | Llama 3.3 70B via Meta |
+| `mistral/mistral-large` | `mistral/mistral-large` | Mistral Large via Mistral AI |
+| `x-ai/grok-2` | `x-ai/grok-2` | Grok 2 via xAI |
+| `deepseek/deepseek-chat` | `deepseek/deepseek-chat` | DeepSeek V3 via DeepSeek |
+
+### Popular OpenRouter 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 |
+| **Llama 3.1 405B** | Meta | 128K | $0.80 | $1.60 | Open source, high quality |
+| **Mistral Large** | Mistral | 128K | $2.00 | $6.00 | European provider, GDPR compliant |
+| **Grok 2** | xAI | 128K | $2.00 | $10.00 | Real-time knowledge, humor |
+| **DeepSeek V3** | DeepSeek | 64K | $0.07 | $1.10 | Cost efficiency, coding |
+| **DeepSeek R1** | DeepSeek | 64K | $0.55 | $2.19 | Reasoning, chain-of-thought |
+
+### Discovering Available Models
+
+Browse the complete model catalog at: **https://openrouter.ai/models**
+
+The catalog includes detailed information for each model:
+- **Pricing**: Input and output costs per million tokens
+- **Context window**: Maximum tokens the model can process
+- **Tool calling**: Whether the model supports function calling
+- **Vision**: Whether the model supports image input
+- **Provider routing**: Available providers for each model
+
+You can also fetch available models programmatically via the OpenRouter API:
+
+```bash
+curl https://openrouter.ai/api/v1/models \
+ -H "Authorization: Bearer $OPENROUTER_API_KEY"
+```
+
+### Configuring OpenRouter Models
+
+Add OpenRouter models to your routatic-proxy configuration:
+
+```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-Based Routing Integration
+
+When `cost_routing.enabled` is set, the selector automatically sorts models by cost and applies your routing preferences:
+
+```json
+{
+ "cost_routing": {
+ "enabled": true,
+ "prefer_providers": ["opencode-go", "openrouter"],
+ "penalty_per_provider": {
+ "openrouter": 0.05
+ },
+ "max_context_window": 200000
+ }
+}
+```
+
+**How cost routing works with OpenRouter:**
+- Models are sorted by combined input + output rates
+- Provider penalties adjust effective costs (e.g., `openrouter: 0.05` adds 5%)
+- `max_context_window` filters out models that can't handle your request size
+- `prefer_providers` intersects with scenario preferences for final selection
+
+### Model Categories Available
+
+| Category | Providers | Example Models |
+|----------|-----------|----------------|
+| **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 |
+| **Specialized** | Various | Qwen, Command R+, Perplexity, and many more |
+
+### Example Configurations
+
+#### Budget-Conscious Setup
+Use cheaper models for most tasks, expensive ones only when needed:
+
+```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"
+ }
+ }
+}
+```
+
+#### Quality-First Setup
+Prioritize quality for critical tasks:
+
+```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"
+ }
+ }
+}
+```
+
+#### Multi-Provider Fallback
+Spread requests across providers for reliability:
+
+```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-Specific Features
+
+#### Provider Routing Preferences
+Request specific providers or enable automatic fallback:
+
+```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
+ }
+ }
+ }
+ }
+}
+```
+
+**Routing options:**
+- `order`: Priority list of providers to try
+- `allow_fallbacks`: Whether to try other providers if primary is down
+- `ignore`: Providers to exclude from routing
+
+#### How OpenRouter Model Catalog Works
+
+OpenRouter models are resolved dynamically from the catalog system:
+
+1. **Catalog Location:** `~/.config/routatic-proxy/catalog/catalog.json`
+2. **Resolution:** Models are keyed as `provider/model-name` (e.g., `openai/gpt-4o`, `anthropic/claude-3.5-sonnet`)
+3. **Dynamic Loading:** New models are automatically available when the catalog is updated — no code changes required
+
+#### Model Resolution from Catalog
+
+The catalog system extracts the provider from the key prefix:
+
+```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`: The model name without provider prefix (`anthropic/claude-3.5-sonnet`)
+- `ResolvedModel.CanonicalName`: The full key (`openrouter/anthropic/claude-3.5-sonnet`)
+
+### Benefits Summary
+
+1. **Access to 100+ Models:** Single API key for OpenAI, Anthropic, Google, Meta, Mistral, and more
+2. **Unified API:** OpenAI-compatible Chat Completions format for all models
+3. **Automatic Fallbacks:** Built-in fallback to alternative providers if a model is unavailable
+4. **Cost Optimization:** Per-token pricing with routing preferences for cost efficiency
+5. **No Vendor Lock-in:** Switch between providers by changing the model ID
+
## Important: API Endpoints
⚠️ **Critical:** Not all models use the same API endpoint! routatic-proxy handles this automatically, but you should know:
diff --git a/Makefile b/Makefile
index d9564bcd..8c06f8fb 100644
--- a/Makefile
+++ b/Makefile
@@ -9,14 +9,18 @@ CMD = ./cmd/routatic-proxy
# ── Development ────────────────────────────────────────────────────
-build:
+build: build-css
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD)
@ln -sf $(BINARY) bin/$(LEGACY_BINARY)
-build-ui:
+build-ui: build-css
CGO_ENABLED=1 go build -tags darwin -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD)
@ln -sf $(BINARY) bin/$(LEGACY_BINARY)
+build-css:
+ @echo "Building Tailwind CSS..."
+ @npx tailwindcss -i internal/gui/assets/tailwind-input.css -o internal/gui/assets/compiled-tailwind.css --minify 2>&1 | grep -v "Browserslist:"
+
dmg: build-ui
@./scripts/build_dmg.sh "$(VERSION)"
@@ -24,7 +28,7 @@ run:
go run -ldflags "$(LDFLAGS)" $(CMD)
test:
- go test ./internal/... ./pkg/... ./cmd/... -v -race
+ go test ./... -v -race
vet:
go vet ./...
diff --git a/README.md b/README.md
index 673cecf9..9e821516 100644
--- a/README.md
+++ b/README.md
@@ -7,204 +7,147 @@
**[English](./README.md)** | [中文](./README-zh.md)
-A Go CLI proxy that lets you route [Claude Code](https://docs.anthropic.com/en/docs/claude-code) requests through multiple upstream providers — [OpenCode Go](https://opencode.ai/docs/go/), [OpenCode Zen](https://opencode.ai/docs/zen/), and [AWS Bedrock](https://aws.amazon.com/bedrock/) — with automatic model selection and format transformation.
+## Supported Providers
-`routatic-proxy` sits between Claude Code and your chosen providers, intercepting Anthropic API requests, transforming them to the appropriate format (OpenAI, Anthropic, Responses, or Gemini), and forwarding them upstream. Claude Code thinks it's talking to Anthropic — but your requests go to the models and providers you configure.
-
-`oc-go-cc` remains available as a compatibility alias, and existing `OC_GO_CC_*` environment variables and `~/.config/oc-go-cc/config.json` files are still recognized.
+
----
+[](https://opencode.ai/docs/go/)
+[](https://opencode.ai/docs/zen/)
+[](https://aws.amazon.com/bedrock/)
+[](https://openrouter.ai/)
+[](https://www.anthropic.com/)
-## macOS GUI Version
+
-This repository provides a native macOS GUI (System Tray + Console Dashboard) for `routatic-proxy`.
+| Provider | Description | Best For |
+|----------|-------------|----------|
+| **OpenCode Go** | High-performance open-source coding models with flat-rate pricing | Daily coding, complex reasoning, cost-effective workloads |
+| **OpenCode Zen** | Curated, tested models with pay-as-you-go pricing | Claude/GPT/Gemini access without multiple API keys |
+| **AWS Bedrock** | Enterprise-grade models on your own AWS infrastructure | Enterprises needing data sovereignty and compliance |
+| **OpenRouter** | Unified API for 100+ LLMs with automatic failover | Experimenting with models from multiple providers |
+| **Anthropic** | Native Claude models with anthropic-first failover mode | Claude-first workflows with OpenCode fallback |
-### Features
-
-- **System Tray Icon** — Control the proxy server directly from the macOS status bar (Start, Stop, Autostart, Quit)
-- **Interactive Dashboard** — A beautiful native console window to view real-time request history, model usage metrics, and easily edit/save your API keys without editing JSON files
-- **App DMG Installer** — Package into a standard macOS app with custom icons and launch support
+---
-### How to Run
+A Go CLI proxy that lets you route [Claude Code](https://docs.anthropic.com/en/docs/claude-code) requests through multiple upstream providers with automatic model selection and format transformation.
-Download the compiled `.dmg` from the **Releases** page of this repository, or run the following command directly:
+`routatic-proxy` sits between Claude Code and your chosen providers, intercepting Anthropic API requests, transforming them to the appropriate format (OpenAI, Anthropic, Responses, or Gemini), and forwarding them upstream. Claude Code thinks it's talking to Anthropic — but your requests go to the models and providers you configure.
-```bash
-# Launch with native macOS GUI
-routatic-proxy ui
-```
+`oc-go-cc` remains available as a compatibility alias, and existing `OC_GO_CC_*` environment variables and `~/.config/oc-go-cc/config.json` files are still recognized.
---
## Why?
-OpenCode Go gives you access to powerful open coding models for **$5/month** (then $10/month). OpenCode Zen provides curated, tested models with pay-as-you-go pricing. AWS Bedrock lets you run models on your own AWS infrastructure. This proxy makes all three work seamlessly with Claude Code's interface — no patches, no forks, just set two environment variables and go.
+OpenCode Go gives you access to powerful open coding models for **$5/month** (then $10/month). OpenCode Zen provides curated, tested models with pay-as-you-go pricing. AWS Bedrock lets you run models on your own AWS infrastructure. OpenRouter gives you unified access to 100+ models. This proxy makes all of them work seamlessly with Claude Code's interface — no patches, no forks, just set two environment variables and go.
## Features
-- **Multi-Provider** — Route through OpenCode Go, OpenCode Zen, or AWS Bedrock from a single config
+- **Multi-Provider** — Route through OpenCode Go, OpenCode Zen, AWS Bedrock, or OpenRouter from a single config
- **Transparent Proxy** — Claude Code sends Anthropic-format requests, proxy transforms to provider-native format and back
- **Model Routing** — Automatically routes to different models based on context (default, thinking, long context, background)
-- **Streaming Scenario Routing** — Configurable routing for streaming requests; enables proper scenario selection for Claude Code multi-agent and review workflows (see [CONFIGURATION.md](CONFIGURATION.md#streaming-scenario-routing))
+- **Streaming Scenario Routing** — Configurable routing for streaming requests (see [CONFIGURATION.md](CONFIGURATION.md#streaming-scenario-routing))
- **Fallback Chains** — If a model fails, automatically tries the next one in your configured chain
- **Anthropic-First Failover** — Keep Claude on Anthropic and use OpenCode only during rate limits or outages
- **Circuit Breaker** — Tracks model health and skips failing models to avoid latency spikes
- **Real-time Streaming** — Full SSE streaming with live format transformation
-- **Tool Calling** — Proper Anthropic tool_use/tool_result <-> OpenAI/Gemini function calling translation
-- **Token Counting** — Uses tiktoken (cl100k_base) for accurate token counting and context threshold detection
-- **JSON Configuration** — Flexible config file with environment variable overrides and `${VAR}` interpolation
-- **Hot Reload** — Watch config file for changes and reload automatically (off by default)
-- **Background Mode** — Run as daemon detached from terminal
-- **Auto-start on Login** — Launch on system startup via launchd (macOS)
+- **Tool Calling** — Proper Anthropic tool_use/tool_result ↔ OpenAI/Gemini function calling translation
+- **Hot Reload** — Watch config file for changes and reload automatically
- **Self-Update** — Check and install the latest release with one command
-## Supported Models
-
-### OpenCode Go Models
-
-| Model | Context | Best For |
-| ------------------ | ------------ | --------------------------------------------- |
-| **GLM-5.2** | ~200K tokens | Critical architecture, production code review |
-| **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 [docs/architecture.md](docs/architecture.md) for system design and request flow details.
-See [MODELS.md](MODELS.md) for the complete model list including costs and routing recommendations.
+## GUI Version
-### OpenCode Zen Models
+This repository provides a cross-platform GUI for `routatic-proxy`:
-Zen provides pay-as-you-go access to additional models:
+- **macOS** — Native Cocoa window with system tray integration (requires CGO). Download the `.dmg` from the **Releases** page.
+- **Linux** — Browser-based GUI via `xdg-open` (default, no CGO required). For system tray: build with `CGO_ENABLED=1` and install `libappindicator-gtk3-devel` (Fedora) or `libayatana-appindicator3-dev` (Ubuntu/Debian).
+- **Windows** — GUI not supported (CLI only).
-- **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
+**Dashboard tabs:** Overview (real-time metrics & model distribution), History (last 1000 requests with filters), Settings (edit config with hot-reload).
-See [MODELS.md](MODELS.md#opencodes-zen) for the full Zen model list.
-
-### Deprecated Models
-
-The following models are deprecated and will be removed:
-
-- GPT 5.2/5.1/5 Codex variants (replaced by GPT 5.3 Codex)
-- Claude Sonnet 4 (replaced by Claude Sonnet 4.5/4.6)
-- GLM 5/4.7/4.6 (replaced by GLM 5.1/5.2)
-- MiniMax M2.1 (replaced by MiniMax M2.5/M2.7/M3)
-- Gemini 3 Pro (replaced by Gemini 3.1 Pro)
-- Kimi K2/K2 Thinking (replaced by Kimi K2.5/K2.6/K2.7 Code)
+```bash
+routatic-proxy ui
+```
-See [MODELS.md](MODELS.md#deprecated-zen-models) for the complete deprecation schedule.
+On macOS, this opens a native window. On Linux, it opens your default browser.
## Quick Start
-### 1. Install
-
```bash
-# macOS / Linux
+# 1. Install
brew tap routatic/tap && brew install routatic-proxy
-# Windows
-scoop bucket add routatic https://github.com/routatic/scoop-bucket && scoop install routatic-proxy
-
-# Docker (with Makefile)
-cp .env.example .env # then put your API key in .env
-make docker-up
-
-# Docker (manual)
-cp .env.example .env
-docker build -t routatic-proxy .
-docker run -d --restart unless-stopped --name routatic-proxy --env-file .env -p 3456:3456 routatic-proxy
-
-# Docker from GitHub Container Registry
-docker pull ghcr.io/routatic/proxy:latest
-docker run -d --restart unless-stopped --name routatic-proxy --env-file .env -p 3456:3456 ghcr.io/routatic/proxy:latest
-```
-
-Or see [INSTALLATION.md](INSTALLATION.md) for more options.
-
-### 2. Initialize Configuration
-
-```bash
+# 2. Initialize configuration
routatic-proxy init
-```
-
-Creates a default config at `~/.config/routatic-proxy/config.json`. Edit it to add your API key, or set the environment variable:
-```bash
+# 3. Set your API key
export ROUTATIC_PROXY_API_KEY=sk-opencode-your-key-here
-```
-
-### 3. Start the Proxy
-```bash
+# 4. Start the proxy
routatic-proxy serve
-```
-
-Stop the Docker container (if using Docker):
-
-```bash
-make docker-stop
-```
-### 4. Configure Claude Code
-
-For the default OpenCode-only mode:
-
-```bash
+# 5. Configure Claude Code
export ANTHROPIC_BASE_URL=http://127.0.0.1:3456
export ANTHROPIC_AUTH_TOKEN=unused
-```
-For Anthropic-first mode, enable `anthropic_first` in the proxy config and set only the base URL. Do not set an API key or auth token: Claude Code will keep using its saved Claude subscription login.
-
-```bash
-export ANTHROPIC_BASE_URL=http://127.0.0.1:3456
-unset ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY
-```
-
-Anthropic-first mode falls back on HTTP 408, 429, 5xx, and connection failures. It honors `Retry-After` and uses one real request to detect recovery, so it does not spend tokens on health checks. See [CONFIGURATION.md](CONFIGURATION.md#anthropic-first-failover).
-
-### 5. Run Claude Code
-
-```bash
+# 6. Run Claude Code
claude
```
+See [INSTALLATION.md](INSTALLATION.md) for Homebrew, Scoop, Docker, and build-from-source options.
+
## CLI Commands
```
routatic-proxy serve Start the proxy server
routatic-proxy serve -b Start in background (detached from terminal)
-routatic-proxy serve --port 8080 Start on a custom port
routatic-proxy stop Stop the running proxy server
routatic-proxy status Check if the proxy is running
routatic-proxy init Create default configuration file
routatic-proxy validate Validate configuration file
-routatic-proxy models List all available models (Go, Zen, Bedrock)
+routatic-proxy models List all available models
+routatic-proxy ui Launch the GUI dashboard
routatic-proxy autostart enable Enable auto-start on login
-routatic-proxy autostart disable Disable auto-start on login
-routatic-proxy autostart status Check autostart status
routatic-proxy update Update to the latest release
-routatic-proxy update --check Show if an update is available
-routatic-proxy update --yes Update without prompting
routatic-proxy --version Show version
```
## Documentation
-| Document | Description |
-| ------------------------------------------------------------ | --------------------------------------------------------------- |
-| [INSTALLATION.md](INSTALLATION.md) | Homebrew, Scoop, build from source, release binaries |
-| [CONFIGURATION.md](CONFIGURATION.md) | Config file reference, env vars, model routing, fallback chains |
-| [MODELS.md](MODELS.md) | Model capabilities, costs, and routing recommendations |
-| [CONTRIBUTING.md](CONTRIBUTING.md) | Development setup, architecture, how it works |
-| [TROUBLESHOOTING.md](TROUBLESHOOTING.md) | Common issues and debug mode |
-| [docs/fedora-setup.md](docs/fedora-setup.md) | Fedora 44 setup guide (installation, systemd, SELinux) |
-| [docs/architecture.md](docs/architecture.md) | System design, request flow, module overview |
-| [docs/reference-api.md](docs/reference-api.md) | HTTP API reference (endpoints, streaming, errors) |
-| [docs/howto-add-model.md](docs/howto-add-model.md) | Adding new models (zero code changes) |
-| [docs/howto-custom-routing.md](docs/howto-custom-routing.md) | Customizing scenario detection and model selection |
-| [docs/howto-debug-routing.md](docs/howto-debug-routing.md) | Debugging routing issues and common problems |
+| Document | Description |
+|----------|-------------|
+| [docs/models.md](docs/models.md) | Model reference across all providers |
+| [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 |
+| [docs/architecture.md](docs/architecture.md) | System design and request flow |
+| [docs/fedora-setup.md](docs/fedora-setup.md) | Fedora 44 setup (systemd, SELinux) |
+| [docs/reference-api.md](docs/reference-api.md) | HTTP API reference |
+| [docs/howto-add-model.md](docs/howto-add-model.md) | Adding new models (zero code changes) |
+| [docs/howto-custom-routing.md](docs/howto-custom-routing.md) | Customizing scenario detection and routing |
+| [docs/howto-debug-routing.md](docs/howto-debug-routing.md) | Debugging routing issues |
+
+## Release Channels
+
+This project uses a dual release channel system. See [RELEASE_PROCESS.md](RELEASE_PROCESS.md) for full details.
+
+### Beta Channel (Automatic)
+- **Trigger:** Every push to `main` branch
+- **Version format:** `v{UPCOMING}.beta.{YYYYMMDD.HHMMSS}` (e.g., `v1.3.0-beta.20260712.143015`)
+- **GitHub release:** Marked as prerelease
+- **Use case:** Get the latest features and bug fixes immediately; ideal for testing
+
+### Production Channel (Manual)
+- **Trigger:** Manual `workflow_dispatch` on `releases` branch
+- **Version format:** `vX.Y.Z` (semantic versioning)
+- **GitHub release:** Marked as stable
+- **Docker tags:** `vX.Y.Z`, `vX.Y`, `vX`, `latest`
+- **Use case:** Stable, tested releases for production use
## Contributing
diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md
new file mode 100644
index 00000000..b50157eb
--- /dev/null
+++ b/RELEASE_PROCESS.md
@@ -0,0 +1,503 @@
+# Dual Release Channel System
+
+This document explains the dual release channel system for routatic-proxy, which supports both automated beta releases and manual production releases.
+
+## Table of Contents
+
+- [Overview](#overview)
+- [Version Naming Conventions](#version-naming-conventions)
+- [Beta Releases](#beta-releases)
+- [Production Releases](#production-releases)
+- [Promoting Beta to Production](#promoting-beta-to-production)
+- [GitHub Release Channel Separation](#github-release-channel-separation)
+- [Triggering Releases](#triggering-releases)
+- [Troubleshooting](#troubleshooting)
+
+## Overview
+
+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 |
+| **Production** | Manual via `workflow_dispatch` | `releases` | `vX.Y.Z` (user specified) | Stable |
+
+### Key Differences
+
+- **Beta releases**: Automatically built and published when code is merged to `main`. These are marked as prereleases on GitHub and are intended for testing.
+- **Production releases**: Triggered manually on the `releases` branch. These are stable releases intended for end users.
+
+## Version Naming Conventions
+
+### Beta Versions
+
+Format: `v{prod-version}-beta-{timestamp}`
+
+- `prod-version`: The current production version (e.g., `v1.2.3`)
+- `timestamp`: UTC timestamp in format `YYYYMMDD-HHMMSS`
+
+**Example:** `v1.2.3-beta-20260712-143000`
+
+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
+
+### Production Versions
+
+Format: `vX.Y.Z` (Semantic Versioning)
+
+- `X`: Major version (breaking changes)
+- `Y`: Minor version (new features, backward compatible)
+- `Z`: Patch version (bug fixes)
+
+**Example:** `v1.2.3`
+
+Production versions are **user-specified** when triggering the release workflow. The workflow does not auto-increment versions.
+
+## Beta Releases
+
+### How They Work
+
+Beta releases are fully automated:
+
+1. Developer merges a pull request to `main`
+2. GitHub Actions triggers the `beta-release.yml` workflow
+3. Workflow runs tests and builds
+4. Creates a GitHub prerelease with:
+ - Cross-platform binaries (Linux, macOS, Windows)
+ - Docker image published to GHCR
+ - AI-generated changelog
+ - Marked as `prerelease: true`
+
+### Beta Release Artifacts
+
+Each beta release includes:
+
+- `routatic-proxy_darwin-amd64` - macOS Intel binary
+- `routatic-proxy_darwin-arm64` - macOS Apple Silicon binary
+- `routatic-proxy_linux-amd64` - Linux Intel binary
+- `routatic-proxy_linux-arm64` - Linux ARM64 binary
+- `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
+
+### 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`)
+
+## Production Releases
+
+### How They Work
+
+Production releases are triggered manually:
+
+1. Ensure the `releases` branch contains the code you want to release
+2. Trigger the `release.yml` workflow via GitHub UI or CLI
+3. Specify the version number (e.g., `v1.2.3`)
+4. Workflow runs tests and builds
+5. Creates a GitHub stable release
+6. Updates Homebrew tap and Scoop bucket
+
+### Production Release Artifacts
+
+Same as beta releases, plus:
+- Published to package managers (Homebrew, Scoop)
+- Docker image tagged as `latest`
+
+### Docker Tags for Production
+
+Production releases are tagged as:
+- `ghcr.io/routatic/proxy:{version}` (e.g., `v1.2.3`)
+- `ghcr.io/routatic/proxy:{major}.{minor}` (e.g., `1.2`)
+- `ghcr.io/routatic/proxy:{major}` (e.g., `1`)
+- `ghcr.io/routatic/proxy:latest`
+
+## Promoting Beta to Production
+
+To promote a beta release to production:
+
+### Step 1: Merge to Releases Branch
+
+```bash
+# Ensure you're on main and have the latest changes
+git checkout main
+git pull origin main
+
+# Checkout releases branch
+git checkout releases
+git pull origin releases
+
+# Merge main into releases
+git merge main
+
+# Push to origin
+git push origin releases
+```
+
+### Step 2: Trigger Production Release
+
+See [Triggering Releases](#triggering-releases) below for detailed instructions.
+
+### Version Selection
+
+When triggering a production release, you must specify the version. Common approaches:
+
+1. **Patch release** (bug fixes): Increment Z in `vX.Y.Z`
+ - `v1.2.3` -> `v1.2.4`
+
+2. **Minor release** (new features): Increment Y in `vX.Y.Z`
+ - `v1.2.3` -> `v1.3.0`
+
+3. **Major release** (breaking changes): Increment X in `vX.Y.Z`
+ - `v1.2.3` -> `v2.0.0`
+
+## GitHub Release Channel Separation
+
+GitHub releases are separated by the `prerelease` flag:
+
+### Beta Releases (Prerelease)
+
+- `prerelease: true`
+- Appears under "Releases" with a "Pre-release" badge
+- Not shown as "Latest" on the repository homepage
+- Intended for testing and early adopters
+
+### Production Releases (Stable)
+
+- `prerelease: false`
+- Appears as the "Latest" release on the repository homepage
+- Shown to all users as the recommended version
+- Triggers package manager updates
+
+### Viewing Releases
+
+Navigate to: `https://github.com/routatic/proxy/releases`
+
+- **Latest stable**: The most recent non-prerelease
+- **All releases**: Includes both stable and prereleases
+- **Tags**: All git tags (including betas without releases)
+
+## Triggering Releases
+
+### Beta Releases (Automatic)
+
+No manual action required. Beta releases trigger automatically when code is merged to `main`.
+
+To verify a beta release was created:
+
+```bash
+# List recent beta tags
+git tag -l "v*-beta-*" --sort=-version:refname | head -10
+
+# Or check GitHub CLI
+gh release list --repo routatic/proxy --limit 20
+```
+
+### Production Releases (Manual)
+
+#### Option 1: GitHub Web UI
+
+1. Navigate to the repository: `https://github.com/routatic/proxy`
+2. Click "Actions" tab
+3. Select "Release" workflow from the left sidebar
+4. Click "Run workflow" button
+5. Select the `releases` branch from dropdown
+6. Enter the version to release (e.g., `v1.2.3`)
+7. Click "Run workflow"
+
+#### Option 2: GitHub CLI
+
+```bash
+# Trigger a production release
+gh workflow run release.yml \
+ --repo routatic/proxy \
+ --ref releases \
+ -f version=v1.2.3
+
+# Monitor the workflow run
+gh run watch --repo routatic/proxy
+```
+
+#### Option 3: REST API
+
+```bash
+# Trigger via GitHub API
+curl -X POST \
+ -H "Authorization: token YOUR_GITHUB_TOKEN" \
+ -H "Accept: application/vnd.github.v3+json" \
+ https://api.github.com/repos/routatic/proxy/actions/workflows/release.yml/dispatches \
+ -d '{
+ "ref": "releases",
+ "inputs": {
+ "version": "v1.2.3"
+ }
+ }'
+```
+
+### Required Permissions
+
+To trigger workflows, you need:
+- Write access to the repository, OR
+- `actions:write` permission scope for API/CLI access
+
+## Troubleshooting
+
+### Beta Release Issues
+
+#### Issue: Beta release not triggering after merge
+
+**Symptoms:** Code merged to `main` but no beta release created.
+
+**Diagnosis:**
+```bash
+# Check if the workflow file exists
+cat .github/workflows/beta-release.yml
+
+# Check recent workflow runs
+gh run list --workflow=beta-release.yml --limit 10
+```
+
+**Solutions:**
+1. Verify the merge was to `main` branch (not another branch)
+2. Check if the workflow is disabled in GitHub Actions settings
+3. Look for syntax errors in the workflow file
+4. Check repository Actions permissions (Settings > Actions > General)
+
+#### Issue: Beta version shows wrong production version
+
+**Symptoms:** Beta tag shows `v0.0.0-beta-...` instead of actual version.
+
+**Diagnosis:**
+```bash
+# Check if production tags exist
+git tag -l "v[0-9]*.[0-9]*.[0-9]*" --sort=-version:refname | head -5
+
+# Run version script locally
+./.github/scripts/get-versions.sh
+```
+
+**Solutions:**
+1. Ensure at least one production version tag exists
+2. The script falls back to `v0.0.0` if no tags match the pattern
+3. Push a production tag manually if needed: `git tag v0.1.0 && git push origin v0.1.0`
+
+#### Issue: Docker image not published
+
+**Symptoms:** Beta release created but no Docker image in GHCR.
+
+**Diagnosis:**
+```bash
+# Check if docker job ran
+gh run view --repo routatic/proxy --job=docker
+```
+
+**Solutions:**
+1. Docker push only works for the main repository (not forks)
+2. Verify `packages: write` permission in workflow
+3. Check GHCR authentication in workflow logs
+
+### Production Release Issues
+
+#### Issue: "Version already exists" error
+
+**Symptoms:** Workflow fails with "tag already exists".
+
+**Diagnosis:**
+```bash
+# Check if tag exists
+git tag -l "v1.2.3"
+
+# Check GitHub releases
+gh release view v1.2.3 --repo routatic/proxy
+```
+
+**Solutions:**
+1. Use a higher version number
+2. Delete the existing tag (if it was a mistake): `git push --delete origin v1.2.3`
+3. Check if a beta release already uses this version pattern
+
+#### Issue: Homebrew/Scoop update fails
+
+**Symptoms:** Release created but package managers not updated.
+
+**Diagnosis:**
+```bash
+# Check if HOMEBREW_PAT or SCOOP_PAT secrets are set
+gh secret list --repo routatic/proxy
+```
+
+**Solutions:**
+1. Verify `HOMEBREW_PAT` secret exists (for homebrew-tap repo access)
+2. Verify `SCOOP_PAT` secret exists (for scoop-bucket repo access)
+3. Check PAT has `repo` scope for the respective repositories
+4. Verify the tap/bucket repositories exist and are accessible
+
+#### Issue: Workflow not appearing in Actions tab
+
+**Symptoms:** Can't find the Release workflow to trigger manually.
+
+**Solutions:**
+1. Ensure the workflow file exists: `.github/workflows/release.yml`
+2. Check if workflow has `workflow_dispatch` trigger configured
+3. Workflow may need to be on the default branch (`main`) to appear
+4. Check if the workflow was disabled due to inactivity
+
+### Version Script Issues
+
+#### Issue: get-versions.sh fails with "date: illegal option"
+
+**Symptoms:** Script fails on macOS with date command errors.
+
+**Solutions:**
+1. The script uses GNU date format: `date -u +"%Y%m%d-%H%M%S"`
+2. On macOS, install coreutils: `brew install coreutils`
+3. Or modify script to use `gdate` instead of `date`
+
+#### Issue: get-versions.sh returns empty version
+
+**Symptoms:** Script outputs empty or malformed JSON.
+
+**Diagnosis:**
+```bash
+# Run with debug mode
+bash -x ./.github/scripts/get-versions.sh
+```
+
+**Solutions:**
+1. Ensure you're in a git repository
+2. Check if `git tag` command works: `git tag -l`
+3. Verify the script is executable: `chmod +x .github/scripts/get-versions.sh`
+
+### General Troubleshooting
+
+#### Check Workflow Logs
+
+```bash
+# List recent runs
+gh run list --limit 20
+
+# View specific run logs
+gh run view --log
+
+# View failed job logs
+gh run view --job= --log
+```
+
+#### Validate Workflow Syntax
+
+```bash
+# Install actionlint
+brew install actionlint
+
+# Validate workflow files
+actionlint .github/workflows/*.yml
+```
+
+#### Test Version Script Locally
+
+```bash
+# Make script executable
+chmod +x .github/scripts/get-versions.sh
+
+# Run and check output
+./.github/scripts/get-versions.sh
+```
+
+#### Common Environment Variables
+
+The workflows expect these secrets:
+
+| Secret | Used In | Purpose |
+|--------|---------|---------|
+| `GITHUB_TOKEN` | All workflows | GitHub API access, releases |
+| `OPENROUTER_API_KEY` | Beta/Release | AI changelog generation |
+| `HOMEBREW_PAT` | Production | Homebrew tap updates |
+| `SCOOP_PAT` | Production | Scoop bucket updates |
+
+---
+
+## Quick Reference
+
+### Beta Release Flow
+
+```
+PR merged to main
+ |
+ v
+GitHub Actions triggers
+ |
+ v
+Run tests (ubuntu-latest)
+ |
+ v
+Build binaries (macos-latest)
+ |
+ v
+Create prerelease on GitHub
+ |
+ v
+Publish Docker image to GHCR
+```
+
+### Production Release Flow
+
+```
+Manual trigger on releases branch
+ |
+ v
+Specify version (e.g., v1.2.3)
+ |
+ v
+Run tests (ubuntu-latest)
+ |
+ v
+Build binaries (macos-latest)
+ |
+ v
+Create stable release on GitHub
+ |
+ v
+Publish Docker image to GHCR
+ |
+ v
+Update Homebrew tap
+ |
+ v
+Update Scoop bucket
+```
+
+### Useful Commands
+
+```bash
+# List all tags
+git tag -l --sort=-version:refname
+
+# List beta tags only
+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
+
+# Delete a local tag
+git tag -d v1.2.3
+
+# Delete a remote tag
+git push --delete origin v1.2.3
+
+# Fetch all tags from remote
+git fetch --tags
+
+# View release assets
+gh release view v1.2.3 --repo routatic/proxy
+
+# Download release asset
+gh release download v1.2.3 --repo routatic/proxy --pattern "routatic-proxy_linux-amd64"
+```
+
+---
+
+*Last updated: 2026-07-12*
diff --git a/REVIEW.md b/REVIEW.md
new file mode 100644
index 00000000..d35af2a1
--- /dev/null
+++ b/REVIEW.md
@@ -0,0 +1,283 @@
+# Code Review Guide
+
+This file defines the review standards for routatic-proxy. It is designed for LLM-based review tools, CI/CD pipelines, and human reviewers. Each check is tagged with its layer and severity.
+
+**Severity:**
+- **BLOCKER** — must fix before merge
+- **REQUIRED** — should fix, merge only with justification
+- **ADVISORY** — best practice, consider
+
+**Layers:**
+- `[T]` — Technical (Go idioms, safety, concurrency)
+- `[L]` — Logical (correctness, edge cases, data flow)
+- `[B]` — Business/Architecture (domain invariants, config, release)
+
+---
+
+## 1. Technical Layer
+
+### 1.1 Go Idioms
+
+| # | Check | Severity | Source |
+|---|-------|----------|--------|
+| T1 | Exported symbols have doc comments (`// Package`, `// TypeName`, `// FuncName`) | REQUIRED | CONTRIBUTING.md |
+| T2 | Constructors use `New` pattern, return pointer, apply defaults for nil/zero params | REQUIRED | `rules/auto-detected/CONSTRUCTOR_PATTERN.md` |
+| T3 | `context.Context` is the first parameter in functions that accept it | REQUIRED | `rules/auto-detected/CONTEXT_PROPAGATION.md` |
+| T4 | Per-attempt contexts use `context.WithTimeout`; `cancel()` is always called (defer or explicit) | REQUIRED | `rules/auto-detected/CONTEXT_PROPAGATION.md` |
+| T5 | Loop boundaries check `ctx.Err()` to respect cancellation | REQUIRED | `rules/auto-detected/CONTEXT_PROPAGATION.md` |
+| T6 | Errors use `fmt.Errorf("context: %w", err)` wrapping, never bare `return err` | REQUIRED | `rules/auto-detected/ERROR_HANDLING.md` |
+| T7 | Sentinel errors declared as `var ErrX = errors.New("...")` at package level | ADVISORY | `rules/auto-detected/ERROR_HANDLING.md` |
+| T8 | Error classification via `func IsXError(err error) bool` helpers, not `strings.Contains` | REQUIRED | `rules/auto-detected/ERROR_HANDLING.md` |
+| T9 | Polymorphic Anthropic fields (`system`, `content`) use `json.RawMessage` with accessor methods | REQUIRED | `rules/auto-detected/JSON_RAWMESSAGE.md` |
+| T10 | Accessors try simplest format first (string before array), fallback to raw bytes | REQUIRED | `rules/auto-detected/JSON_RAWMESSAGE.md` |
+| T11 | Logging uses `log/slog` with key-value pairs, never `log.Printf` or `fmt.Println` for diagnostics | REQUIRED | `rules/auto-detected/SLOG_LOGGING.md` |
+| T12 | Log levels: Debug=routine, Info=significant events, Warn=recoverable issues, Error=failures | REQUIRED | `rules/auto-detected/SLOG_LOGGING.md` |
+| T13 | Nil logger defaults to `slog.Default()` | ADVISORY | `rules/auto-detected/SLOG_LOGGING.md` |
+| T14 | `sync.Mutex` embedded in structs, `mu.Lock()` / `defer mu.Unlock()` pattern | REQUIRED | `rules/auto-detected/SYNC_MUTEX.md` |
+| T15 | Separate mutexes for independent state (not one big lock) | ADVISORY | `rules/auto-detected/SYNC_MUTEX.md` |
+| T16 | Use `sync.RWMutex` when reads dominate writes | ADVISORY | `rules/auto-detected/SYNC_MUTEX.md` |
+| T17 | Ensure `gofmt` compliance (run `make lint`) | BLOCKER | CONTRIBUTING.md |
+| T18 | All files compile with `CGO_ENABLED=0 go build` (default) | BLOCKER | Makefile |
+
+### 1.2 Memory & Resource Safety
+
+| # | Check | Severity | Rationale |
+|---|-------|----------|-----------|
+| T19 | `defer` is not used inside `for`/`for-range` loops (runs on function return, leaks resources) | BLOCKER | TOCTOU + leak pattern |
+| T20 | HTTP response bodies are closed explicitly (not deferred in loops) | BLOCKER | Resource leak |
+| T21 | Test-bind-then-close (TOCTOU) patterns are absent — listeners are bound once and kept | BLOCKER | Race condition |
+| T22 | `defer cancel()` or explicit `cancel()` present for every `context.WithTimeout`/`WithCancel` | REQUIRED | Context leak |
+| T23 | No goroutine leaks: goroutines have a shutdown signal (ctx.Done(), channel close, WaitGroup) | REQUIRED | Production reliability |
+| T24 | No `panic()` in library code; recover only at top-level HTTP handler boundaries | REQUIRED | Crash safety |
+
+### 1.3 Frontend (HTML/CSS/JS)
+
+| # | Check | Severity | Rationale |
+|---|-------|----------|-----------|
+| T25 | No external CDN scripts or stylesheets — all assets bundled via `//go:embed` | REQUIRED | Offline capability, CSP, supply-chain |
+| T26 | CSP header restricts `default-src 'self'`; only `'unsafe-inline'` for scripts and styles | REQUIRED | XSS mitigation |
+| T27 | No inline `onclick`/`onchange` handlers referencing undefined functions (check against app.js) | REQUIRED | Silent failures |
+| T28 | `data-i18n` keys exist in `TRANSLATIONS.en` (and `TRANSLATIONS.zh` if Chinese) | REQUIRED | i18n completeness |
+| T29 | Translations use `t(key)` function, not direct string references | REQUIRED | I18n correctness |
+| T30 | No `confirm()`/`prompt()`/`alert()` in production code — use modal or `` instead | ADVISORY | UX quality |
+| T31 | Loading states shown during data fetches (spinner or skeleton) | ADVISORY | UX quality |
+| T32 | `/api/*` endpoints accessed by the frontend correspond to real backend handlers | REQUIRED | 404 errors |
+
+### 1.4 Configuration & Secrets
+
+| # | Check | Severity | Rationale |
+|---|-------|----------|-----------|
+| T33 | API keys loaded from env vars or config file, never hardcoded | BLOCKER | Security |
+| T34 | Config values support `${VAR}` env interpolation | REQUIRED | `internal/config/loader.go` |
+| T35 | Provider-specific keys (`*_API_KEY`) take precedence over global; documented precedence chain | REQUIRED | CLAUDE.md |
+| T36 | Port numbers are configurable via env var or CLI flag, not hardcoded | ADVISORY | Deploy flexibility |
+| T37 | Config file writes are atomic (write temp, rename) | REQUIRED | Crash safety |
+
+### 1.5 Documentation Completeness
+
+| # | Check | Severity | Rationale |
+|---|-------|----------|-----------|
+| T38 | Every exported symbol has a `// ` doc comment — packages, types, funcs, consts, vars | REQUIRED | CONTRIBUTING.md, godoc |
+| T39 | Package-level doc comments describe the package's responsibility, not its file contents | REQUIRED | Go convention |
+| T40 | Non-obvious logic has inline comments explaining *why*, not *what* | REQUIRED | Maintainability |
+| T41 | Config changes (new fields, changed defaults, removed keys) update the example config and CLAUDE.md | REQUIRED | First-run UX, LLM accuracy |
+| T42 | API endpoint changes update any user-facing docs (README, ARCHITECTURE, CLAUDE.md) | REQUIRED | Alignment — code vs docs |
+| T43 | CHANGELOG or release notes updated for user-facing changes (new features, breaking changes, deprecations) | REQUIRED | Release readiness |
+| T44 | `docs/` directory or inline `.md` files in the affected package are updated to reflect the change | ADVISORY | Discoverability |
+| T45 | Deprecated symbols use `// Deprecated:` doc comment with migration path | ADVISORY | API hygiene |
+
+### 1.6 Duplication & Reuse
+
+| # | Check | Severity | Rationale |
+|---|-------|----------|-----------|
+| T46 | New code first searches for existing helpers, types, or packages that already serve the purpose — no reinventing | REQUIRED | DRY, consistency |
+| T47 | Shared logic (field mapping, type conversion, JSON construction) is extracted to named helpers, not duplicated inline | REQUIRED | Single source of truth |
+| T48 | Repeated field-map patterns between types use a helper function, not copy-paste blocks | REQUIRED | `internal/catalog/migrate.go` precedent |
+| T49 | New scenarios, fallbacks, or model configs use existing mechanisms (scenario map, config struct, catalog), not inline conditionals | REQUIRED | Config-driven architecture |
+| T50 | Common transformations (Anthropic↔OpenAI field renames, token math, cost lookups) use the existing `internal/transformer/` or `internal/catalog/` packages — no ad-hoc reimplementation | REQUIRED | Correctness, maintainability |
+| T51 | New types reuse existing project types (e.g. `pkg/types.Message`), not inline structs | REQUIRED | API contract integrity |
+| T52 | Existing constructor, error, logger, and mutex patterns are followed — not one-off alternatives | ADVISORY | `rules/auto-detected/` consistency |
+
+---
+
+## 2. Logical Layer
+
+### 2.1 Correctness
+
+| # | Check | Severity | Rationale |
+|---|-------|----------|-----------|
+| L1 | Map value mutations re-assign to map: `v.Field = x; m[key] = v` (value type semantics in Go) | BLOCKER | Silent data loss |
+| L2 | All branches of conditional assignments are complete — no omitted fields | REQUIRED | Data inconsistency |
+| L3 | Fallback chain iteration correctly identifies the primary model (index 0) vs fallbacks | REQUIRED | Routing correctness |
+| L4 | Circuit breaker counts only retryable errors (5xx), not 4xx or client cancellation | REQUIRED | `internal/router/fallback.go` |
+| L5 | Stream idle timeout is per-`Read`, not server-level `WriteTimeout` | REQUIRED | CLAUDE.md stream policy |
+| L6 | Client disconnects during stream are logged at Debug, not Error | REQUIRED | CLAUDE.md stream policy |
+| L7 | `hasToolUsage` checks only unambiguous tool-calling patterns, not everyday words like "bash" | REQUIRED | False positives |
+| L8 | Model fallbacks carry correct config (provider, temperature, max_tokens) — not just model_id | REQUIRED | Config-driven routing |
+
+### 2.2 Edge Cases
+
+| # | Check | Severity | Rationale |
+|---|-------|----------|-----------|
+| L9 | Nil `history.History` or `metrics.Metrics` returns zero values, not panics | REQUIRED | CLAUDE.md nil safety |
+| L10 | Empty model chain returns an informative error, not panic/empty response | REQUIRED | First-run UX |
+| L11 | Zero token count, zero cost, empty trend data render as `—` not `$NaN` or `undefined` | REQUIRED | Analytics dashboard |
+| L12 | Headless mode (`--headless`, `serve`) does not attempt GUI operations | REQUIRED | Cross-platform |
+| L13 | Port-scan fallback (GUI port 3445→3454) notifies user, doesn't silently pick different port | ADVISORY | User awareness |
+| L14 | JSON body is limited with `http.MaxBytesReader` before parsing | REQUIRED | DOS prevention |
+| L15 | SSE stream transformers handle partial/incomplete JSON chunks without panic | REQUIRED | Streaming robustness |
+
+### 2.3 Data Flow
+
+| # | Check | Severity | Rationale |
+|---|-------|----------|-----------|
+| L16 | Analytics cost query uses `SUM(tokens * COALESCE(rate, 0))` not `SUM(tokens) * rate` | BLOCKER | Per-model pricing |
+| L17 | JSON field names in Go struct tags match what the frontend reads | BLOCKER | KPI correctness |
+| L18 | Frontend references backend struct fields by their JSON tag, not Go field name | BLOCKER | Serialization mismatch |
+| L19 | Auto-detected scenario keys in FallbackModule match config model keys (no hardcoded subset) | REQUIRED | Feature detection |
+| L20 | Fallback save patch sends `{fallbacks: {scenario1: [...], ...}}` matching actual config structure | REQUIRED | Config integrity |
+| L21 | Catalog resolution silently degrades with a warning log when catalog is unavailable | REQUIRED | Debuggability |
+
+---
+
+## 3. Business / Architecture Layer
+
+### 3.1 Routing Invariants
+
+| # | Check | Severity | Rationale |
+|---|-------|----------|-----------|
+| B1 | Model routing is config-driven, not code-driven — adding a model requires zero Go changes | BLOCKER | CLAUDE.md architecture |
+| B2 | Scenario detection priority is: Long Context > Complex > Think > Background > Default | REQUIRED | `internal/router/scenarios.go` |
+| B3 | Streaming requests use fast models (Qwen3.7 Plus) for better TTFT | REQUIRED | CLAUDE.md |
+| B4 | Vision requests route to vision-capable models; non-vision models reject image content | REQUIRED | Capability check |
+| B5 | Cost-based routing filters by constraints (tools, vision, reasoning, context) before sorting by price | REQUIRED | `internal/router/selector.go` |
+| B6 | `respect_requested_model` bypasses scenario routing; provider-qualified refs that fail catalog resolution return error | REQUIRED | `internal/router/model_router.go` |
+
+### 3.2 Provider & Model Integrity
+
+| # | Check | Severity | Rationale |
+|---|-------|----------|-----------|
+| B7 | Anthropic tool format disabled models (`anthropic_tools_disabled: true`) route through Chat Completions transform | REQUIRED | CLAUDE.md |
+| B8 | Bedrock xAI models get `/openai` path appended; other Bedrock models use standard path | REQUIRED | `internal/provider/aws_bedrock.go` |
+| B9 | Catalog schema: models keyed as `provider/model-name`, resolved via `Resolve`/`ResolveShort` | REQUIRED | `internal/catalog/resolve.go` |
+| B10 | Catalog seed prices are idempotent — update only where `cost_input_per_m IS NULL OR 0` | REQUIRED | `internal/storage/database.go` |
+| B11 | Free-tier models seed-price matching: specific entries first, `-free` generic catch-all as fallback | ADVISORY | `seed_prices.json` ordering |
+
+### 3.3 Release & Build
+
+| # | Check | Severity | Rationale |
+|---|-------|----------|-----------|
+| B12 | `make build` runs `build-css` before `go build` (Tailwind CSS generation) | BLOCKER | Frontend asset delivery |
+| B13 | `make test` runs with `-race` detector | REQUIRED | CONTRIBUTING.md |
+| B14 | `make lint` checks `gofmt` and `go vet` | REQUIRED | CONTRIBUTING.md |
+| B15 | Beta releases auto-generated on push to `main`; production releases manual on `releases` branch | REQUIRED | CLAUDE.md |
+| B16 | Version follows `vX.Y.Z` for production, `vX.Y.Z-beta.TIMESTAMP` for beta | REQUIRED | CLAUDE.md |
+| B17 | Docker image builds with `CGO_ENABLED=0` for portability | REQUIRED | Cross-platform |
+
+### 3.4 API Contract
+
+| # | Check | Severity | Rationale |
+|---|-------|----------|-----------|
+| B18 | `POST /v1/messages` accepts and returns Anthropic Messages API format | BLOCKER | Claude Code compatibility |
+| B19 | SSE events are transformed in-flight, not buffered | REQUIRED | CLAUDE.md |
+| B20 | `/health` returns 200 with `{"status":"ok"}` | REQUIRED | Health checks |
+| B21 | Dashboard `/api/*` endpoints are under `internal/gui/` and require no auth (local-only) | REQUIRED | Design decision |
+
+---
+
+## 4. Code Smell Baseline (Fowler, Refactoring ch.3)
+
+These are always judgement calls. A documented repo standard overrides any smell check.
+
+| # | Smell | What to look for | Severity |
+|---|-------|------------------|----------|
+| S1 | **Mysterious Name** | Function/variable/type name doesn't reveal what it does or holds | ADVISORY |
+| S2 | **Duplicated Code** | Same logic shape in more than one hunk or file → check T46–T48 | ADVISORY |
+| S3 | **Feature Envy** | Method reaches into another object's data more than its own | ADVISORY |
+| S4 | **Data Clumps** | Same fields/params travelling together ← extract to a type | ADVISORY |
+| S5 | **Primitive Obsession** | String for a domain concept that deserves its own small type | ADVISORY |
+| S6 | **Repeated Switches** | Same switch/if-cascade on same type recurs across the change | ADVISORY |
+| S7 | **Shotgun Surgery** | One logical change forces scattered edits across many files | ADVISORY |
+| S8 | **Divergent Change** | One file modified for several unrelated reasons | ADVISORY |
+| S9 | **Speculative Generality** | Abstraction/params/hooks for needs the spec doesn't have | ADVISORY |
+| S10 | **Message Chains** | Long `a.b().c().d()` navigation | ADVISORY |
+| S11 | **Middle Man** | Class/function that mostly just delegates onward | ADVISORY |
+| S12 | **Refused Bequest** | Subclass/implementer ignores most of what it inherits | ADVISORY |
+
+---
+
+## 5. Review Process
+
+### 5.1 Pre-Review Checklist (for CI)
+
+```bash
+# Build + CSS generation
+make build
+
+# Lint (gofmt + go vet)
+make lint
+
+# Tests with race detector
+make test
+```
+
+### 5.2 Review Command
+
+```bash
+# Full diff against base
+git diff main...HEAD --stat
+
+# Focus areas by package
+git diff main...HEAD -- internal/router/ # Routing correctness
+git diff main...HEAD -- internal/gui/ # Dashboard + analytics
+git diff main...HEAD -- internal/storage/ # Persistence + queries
+git diff main...HEAD -- internal/catalog/ # Model resolution
+git diff main...HEAD -- internal/handlers/ # API contracts
+git diff main...HEAD -- cmd/ # CLI behavior
+```
+
+### 5.3 Commit Message Convention
+
+```
+:
+
+feat: New feature
+fix: Bug fix
+refactor: Code restructuring
+docs: Documentation
+chore: Build/tooling
+test: Tests
+```
+
+### 5.4 Two-Axis Reporting
+
+Each review should produce findings under two independent axes:
+
+- **Standards** — does the code follow the documented rules (sections 1–3)? Distinguish hard violations (tagged BUILDER/REQUIRED) from judgement calls (ADVISORY/smells).
+- **Spec** — does the code faithfully implement what was asked? Identify missing requirements, scope creep, and wrong implementations separately.
+
+Report them side by side, never reranked into a single score — a change can pass one axis and fail the other.
+
+---
+
+## 6. Quick Reference: File-to-Rule Mapping
+
+**Universal rules (all files):** T38–T45 (documentation), T46 (search before invent), T47 (extract shared logic), T48 (no copy-paste mapping), T50 (reuse transformer/catalog), T51 (reuse project types), T52 (follow established patterns), S2 (no duplicated code), S6 (no repeated switches), S7 (shotgun surgery).
+
+| File / Package | Applicable Rules |
+|----------------|-----------------|
+| `internal/router/` | T3, T4, T5, T6, T14, T15, L3, L4, L8, B1–B6 |
+| `internal/handlers/` | T9, T10, T11, T24, L15, B18, B19, B20 |
+| `internal/transformer/` | T9, T10, L15, B18 |
+| `internal/client/` | T4, T6, T22, B7 |
+| `internal/provider/` | T3, T4, T6, B8 |
+| `internal/gui/` | T25–T32, T36, L9, L13, L17–L20, B12, B21 |
+| `internal/storage/` | T1, L16, L21, B10, B11 |
+| `internal/catalog/` | L21, B9 |
+| `internal/config/` | T34, T35, T37 |
+| `internal/metrics/` | T14, T15, T23 |
+| `internal/server/` | T19, T20, T21, T26 |
+| `internal/daemon/` | T22, L12 |
+| `cmd/routatic-proxy/` | T17, T18, T36, L12, B12–B17 |
+| `internal/gui/assets/` | T25–T32 |
+| `pkg/types/` | T9, T10 |
diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go
index 0aed47a8..7e9d778a 100644
--- a/cmd/routatic-proxy/main.go
+++ b/cmd/routatic-proxy/main.go
@@ -6,16 +6,20 @@ import (
"encoding/json"
"fmt"
"log/slog"
+ "net/http"
"os"
+ "os/signal"
"path/filepath"
"sort"
"strings"
+ "syscall"
"time"
"github.com/routatic/proxy/internal/catalog"
"github.com/routatic/proxy/internal/config"
"github.com/routatic/proxy/internal/daemon"
"github.com/routatic/proxy/internal/debug"
+ "github.com/routatic/proxy/internal/gui"
"github.com/routatic/proxy/internal/server"
"github.com/routatic/proxy/internal/storage"
"github.com/spf13/cobra"
@@ -30,7 +34,6 @@ const (
var version = "dev"
func main() {
- setupDefaultCommand()
rootCmd := &cobra.Command{
Use: appName,
Aliases: []string{"oc-go-cc"},
@@ -54,8 +57,9 @@ Legacy ~/.config/oc-go-cc/config.json and OC_GO_CC_* environment variables are s
rootCmd.AddCommand(modelsCmd())
rootCmd.AddCommand(catalogCmd())
rootCmd.AddCommand(autostartCmd())
- rootCmd.AddCommand(updateCmd())
- addPlatformCommands(rootCmd)
+ rootCmd.AddCommand(updateCmd)
+ rootCmd.AddCommand(startCmd())
+ rootCmd.AddCommand(updateChannelCmd)
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
@@ -216,6 +220,213 @@ func serveCmd() *cobra.Command {
return cmd
}
+// startCmd returns the command to start both the proxy server and GUI dashboard.
+func startCmd() *cobra.Command {
+ var configPath string
+ var port int
+ var background bool
+ var headless bool
+ var daemonize bool // hidden internal flag
+
+ cmd := &cobra.Command{
+ Use: "start",
+ Short: "Start the proxy server (with optional dashboard)",
+ Long: `Start the proxy server and optionally the GUI dashboard.
+
+The proxy runs on the configured port (default 3456). Use --headless to
+skip the dashboard (equivalent to the legacy "serve" command). The dashboard
+is available at http://127.0.0.1:3445 when not headless. All usage data
+is persisted to SQLite.
+
+Press Ctrl+C to stop the server.`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ // Handle background mode: fork and exit parent
+ if background && !daemonize {
+ opts := daemon.BackgroundOpts{
+ ConfigPath: configPath,
+ Port: port,
+ Command: "start",
+ }
+ return daemon.ForkIntoBackground(opts)
+ }
+
+ // Override config path if provided.
+ if configPath != "" {
+ _ = os.Setenv("ROUTATIC_PROXY_CONFIG", configPath)
+ }
+
+ cfg, err := config.Load()
+ if err != nil {
+ return fmt.Errorf("failed to load config: %w", err)
+ }
+
+ if err := ensureCatalogSynced(cfg, configPath, time.Now().UTC()); err != nil {
+ return fmt.Errorf("failed to sync catalog: %w", err)
+ }
+
+ // Ensure SQLite database exists.
+ if err := ensureDatabase(); err != nil {
+ return fmt.Errorf("failed to initialize database: %w", err)
+ }
+
+ // Override port if provided via flag.
+ if port != 0 {
+ cfg.Port = port
+ }
+
+ pidPath := getPIDPath()
+
+ // Check if already running before writing this process' PID.
+ if !daemonize {
+ if pid, err := daemon.GetPID(pidPath); err == nil {
+ // Check if process is still running.
+ if daemon.IsProcessRunning(pid) {
+ return fmt.Errorf("server is already running (PID %d)", pid)
+ }
+ // Stale PID file, clean up.
+ _ = os.Remove(pidPath)
+ }
+ }
+
+ // Daemonize setup (child process after re-exec).
+ if daemonize {
+ paths, err := daemon.DefaultPaths()
+ if err != nil {
+ return err
+ }
+ if err := paths.EnsureConfigDir(); err != nil {
+ return err
+ }
+ if err := daemon.DaemonizeSetup(paths); err != nil {
+ return err
+ }
+ } else {
+ // Ensure config directory exists before writing PID file.
+ paths, err := daemon.DefaultPaths()
+ if err != nil {
+ return err
+ }
+ if err := paths.EnsureConfigDir(); err != nil {
+ return err
+ }
+ // Write PID file for foreground mode.
+ if err := daemon.WritePID(pidPath, os.Getpid()); err != nil {
+ return fmt.Errorf("failed to write PID file: %w", err)
+ }
+ }
+ defer func() { _ = os.Remove(pidPath) }()
+
+ // Create atomic config for hot reload support.
+ atomicCfg := config.NewAtomicConfig(cfg, config.ResolveConfigPath())
+
+ // Re-apply CLI port override on every reload.
+ if port != 0 {
+ atomicCfg.OnReload(func(newCfg *config.Config) {
+ newCfg.Port = port
+ })
+ }
+
+ // Create and start proxy server.
+ srv, err := server.NewServer(atomicCfg, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create server: %w", err)
+ }
+
+ // Start config watcher for hot reload.
+ if cfg.HotReload {
+ watchCtx, watchCancel := context.WithCancel(context.Background())
+ defer watchCancel()
+ go func() {
+ if err := config.WatchConfig(watchCtx, atomicCfg); err != nil && err != context.Canceled {
+ slog.Error("config watcher failed", "error", err)
+ }
+ }()
+ }
+
+ // Context for graceful shutdown.
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ var guiSrv *gui.Server
+
+ // Start proxy in background.
+ go func() {
+ if err := srv.Start(); err != nil && err != http.ErrServerClosed {
+ slog.Error("proxy server error", "error", err)
+ cancel()
+ }
+ }()
+
+ // Print startup info.
+ fmt.Printf("Starting %s v%s\n", appName, version)
+ fmt.Printf("Proxy listening on %s:%d\n", cfg.Host, cfg.Port)
+ fmt.Println()
+ fmt.Println("Configure Claude Code with:")
+ fmt.Printf(" export ANTHROPIC_BASE_URL=http://%s:%d\n", cfg.Host, cfg.Port)
+ if cfg.AnthropicFirst.Enabled {
+ fmt.Println(" unset ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY")
+ } else {
+ fmt.Println(" export ANTHROPIC_AUTH_TOKEN=unused")
+ }
+
+ if !headless {
+ // Start GUI server on port 3445.
+ guiSrv = gui.New(gui.Options{
+ History: srv.History,
+ Metrics: srv.Metrics(),
+ AtomicConfig: atomicCfg,
+ ProxyPort: cfg.Port,
+ Storage: srv.Storage(),
+ })
+ guiSrv.SetProxyRunning(true)
+
+ guiURL, err := guiSrv.Start(ctx)
+ if err != nil {
+ cancel()
+ return fmt.Errorf("start gui server: %w", err)
+ }
+
+ // Open GUI (macOS: native webview, Linux/Windows: print URL)
+ if err := openGUI(guiURL); err != nil {
+ slog.Warn("GUI error", "error", err)
+ fmt.Printf("\nDashboard: %s\n", guiURL)
+ fmt.Println("\nPress Ctrl+C to stop.")
+ }
+ } else {
+ fmt.Println("\nRunning in headless mode (no dashboard). Press Ctrl+C to stop.")
+ }
+
+ // Wait for signal.
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
+ select {
+ case <-sigCh:
+ fmt.Println("\nShutting down...")
+ case <-ctx.Done():
+ }
+
+ // Graceful shutdown.
+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer shutdownCancel()
+ _ = srv.Shutdown(shutdownCtx)
+ if guiSrv != nil {
+ _ = guiSrv.Shutdown(shutdownCtx)
+ }
+
+ return nil
+ },
+ }
+
+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
+ cmd.Flags().IntVarP(&port, "port", "p", 0, "Override proxy listen port")
+ cmd.Flags().BoolVarP(&background, "background", "b", false, "Run as background daemon")
+ cmd.Flags().BoolVarP(&headless, "headless", "H", false, "Skip dashboard (run proxy only)")
+ cmd.Flags().BoolVar(&daemonize, "_daemonize", false, "Internal use only")
+ _ = cmd.Flags().MarkHidden("_daemonize")
+
+ return cmd
+}
+
// stopCmd returns the command to stop the proxy server.
func stopCmd() *cobra.Command {
return &cobra.Command{
diff --git a/cmd/routatic-proxy/start_gui_darwin.go b/cmd/routatic-proxy/start_gui_darwin.go
new file mode 100644
index 00000000..411d54d7
--- /dev/null
+++ b/cmd/routatic-proxy/start_gui_darwin.go
@@ -0,0 +1,42 @@
+//go:build darwin && cgo
+
+package main
+
+import (
+ "fmt"
+
+ "github.com/energye/systray"
+ "github.com/webview/webview"
+)
+
+func openGUI(guiURL string) error {
+ fmt.Printf("\nDashboard: %s\n", guiURL)
+ fmt.Println("Opening native window...")
+
+ // Set up system tray first (initializes on main thread), then start webview
+ // run loop inside onReady so both coexist: tray menu + native webview window.
+ systray.Run(func() {
+ systray.SetTitle("routatic-proxy")
+ systray.SetTooltip("routatic-proxy is running")
+ mQuit := systray.AddMenuItem("Quit", "Stop the proxy")
+
+ wv := webview.New(false)
+ wv.SetTitle("routatic-proxy")
+ wv.SetSize(1200, 800, webview.HintNone)
+ wv.Navigate(guiURL)
+
+ go func() {
+ <-mQuit.ClickedCh
+ wv.Dispatch(func() {
+ wv.Terminate()
+ })
+ systray.Quit()
+ }()
+
+ wv.Run()
+ wv.Destroy()
+ systray.Quit()
+ }, nil)
+
+ return nil
+}
diff --git a/cmd/routatic-proxy/start_gui_darwin_nocgo.go b/cmd/routatic-proxy/start_gui_darwin_nocgo.go
new file mode 100644
index 00000000..ca5fdace
--- /dev/null
+++ b/cmd/routatic-proxy/start_gui_darwin_nocgo.go
@@ -0,0 +1,13 @@
+//go:build darwin && !cgo
+
+package main
+
+import (
+ "fmt"
+)
+
+func openGUI(guiURL string) error {
+ fmt.Printf("Dashboard: %s\n", guiURL)
+ fmt.Println("\nPress Ctrl+C to stop.")
+ return nil
+}
diff --git a/cmd/routatic-proxy/start_gui_other.go b/cmd/routatic-proxy/start_gui_other.go
new file mode 100644
index 00000000..3c34da80
--- /dev/null
+++ b/cmd/routatic-proxy/start_gui_other.go
@@ -0,0 +1,13 @@
+//go:build !darwin
+
+package main
+
+import (
+ "fmt"
+)
+
+func openGUI(guiURL string) error {
+ fmt.Printf("Dashboard: %s\n", guiURL)
+ fmt.Println("\nPress Ctrl+C to stop.")
+ return nil
+}
diff --git a/cmd/routatic-proxy/ui_darwin.go b/cmd/routatic-proxy/ui_darwin.go
deleted file mode 100644
index 7dccda79..00000000
--- a/cmd/routatic-proxy/ui_darwin.go
+++ /dev/null
@@ -1,410 +0,0 @@
-//go:build darwin && cgo
-
-package main
-
-/*
-#cgo CFLAGS: -x objective-c
-#cgo LDFLAGS: -framework Cocoa
-#import
-
-void triggerOpenWindow();
-
-static inline void DispatchOpenWindow() {
- dispatch_async(dispatch_get_main_queue(), ^{
- triggerOpenWindow();
- });
-}
-
-extern void goWindowWillClose();
-
-static inline void registerWindowCloseObserver(void* windowPtr) {
- NSWindow* win = (__bridge NSWindow*)windowPtr;
- [[NSNotificationCenter defaultCenter] addObserverForName:NSWindowWillCloseNotification
- object:win
- queue:[NSOperationQueue mainQueue]
- usingBlock:^(NSNotification *note) {
- goWindowWillClose();
- }];
-}
-
-static inline void makeWindowKeyAndActive(void* windowPtr) {
- NSWindow* win = (__bridge NSWindow*)windowPtr;
- [NSApp activateIgnoringOtherApps:YES];
- [win makeKeyAndOrderFront:nil];
-}
-
-static inline void setupMacMenus() {
- NSMenu *mainMenu = [[NSMenu alloc] init];
-
- NSMenuItem *appMenuItem = [[NSMenuItem alloc] init];
- [mainMenu addItem:appMenuItem];
- NSMenu *appMenu = [[NSMenu alloc] init];
- [appMenu addItemWithTitle:@"Quit RoutaticProxy" action:@selector(terminate:) keyEquivalent:@"q"];
- [appMenuItem setSubmenu:appMenu];
-
- NSMenuItem *editMenuItem = [[NSMenuItem alloc] init];
- [mainMenu addItem:editMenuItem];
- NSMenu *editMenu = [[NSMenu alloc] initWithTitle:@"Edit"];
- [editMenu addItemWithTitle:@"Undo" action:@selector(undo:) keyEquivalent:@"z"];
- [editMenu addItemWithTitle:@"Redo" action:@selector(redo:) keyEquivalent:@"Z"];
- [editMenu addItem:[NSMenuItem separatorItem]];
- [editMenu addItemWithTitle:@"Cut" action:@selector(cut:) keyEquivalent:@"x"];
- [editMenu addItemWithTitle:@"Copy" action:@selector(copy:) keyEquivalent:@"c"];
- [editMenu addItemWithTitle:@"Paste" action:@selector(paste:) keyEquivalent:@"v"];
- [editMenu addItemWithTitle:@"Select All" action:@selector(selectAll:) keyEquivalent:@"a"];
- [editMenuItem setSubmenu:editMenu];
-
- [NSApp setMainMenu:mainMenu];
-}
-*/
-import "C"
-
-import (
- "context"
- "fmt"
- "io"
- "log/slog"
- "net/http"
- "os"
- "os/signal"
- "path/filepath"
- "strings"
- "sync"
- "syscall"
- "time"
-
- "github.com/routatic/proxy/internal/config"
- "github.com/routatic/proxy/internal/daemon"
- "github.com/routatic/proxy/internal/debug"
- "github.com/routatic/proxy/internal/gui"
- "github.com/routatic/proxy/internal/server"
- "github.com/routatic/proxy/internal/tray"
- "github.com/spf13/cobra"
- "github.com/webview/webview_go"
-)
-
-var (
- globalGUIURL string
- currentWv webview.WebView
- wvMu sync.Mutex
- setupMenusOnce sync.Once
-)
-
-//export goWindowWillClose
-func goWindowWillClose() {
- wvMu.Lock()
- wv := currentWv
- if wv == nil {
- wvMu.Unlock()
- return
- }
- currentWv = nil
- wvMu.Unlock()
-
- wv.Destroy()
-}
-
-//export triggerOpenWindow
-func triggerOpenWindow() {
- openWebview()
-}
-
-func openWebview() {
- wvMu.Lock()
- if currentWv != nil {
- winPtr := currentWv.Window()
- C.makeWindowKeyAndActive(winPtr)
- wvMu.Unlock()
- return
- }
-
- currentWv = webview.New(true)
- currentWv.SetTitle("routatic-proxy Console")
- currentWv.SetSize(860, 560, webview.HintNone)
-
- setupMenusOnce.Do(func() {
- C.setupMacMenus()
- })
-
- winPtr := currentWv.Window()
- C.registerWindowCloseObserver(winPtr)
- C.makeWindowKeyAndActive(winPtr)
-
- currentWv.Navigate(globalGUIURL)
- wvMu.Unlock()
-}
-
-var uiCmd = &cobra.Command{
- Use: "ui",
- Short: "Launch GUI dashboard (macOS only)",
- Long: `Start the proxy server and open the graphical dashboard.
-The proxy runs in the background; closing the window keeps it running.
-Use the tray icon to reopen the window or quit entirely.`,
- RunE: func(cmd *cobra.Command, args []string) error {
- configPath, _ := cmd.Flags().GetString("config")
- if configPath == "" {
- configPath = config.ResolveConfigPath()
- } else {
- _ = os.Setenv("ROUTATIC_PROXY_CONFIG", configPath)
- }
-
- if _, err := os.Stat(configPath); os.IsNotExist(err) {
- slog.Info("Config file not found, auto-initializing default config", "path", configPath)
- configDir := filepath.Dir(configPath)
- if err := os.MkdirAll(configDir, 0700); err != nil {
- return fmt.Errorf("failed to create config directory: %w", err)
- }
- if err := os.WriteFile(configPath, []byte(getDefaultConfig()), 0600); err != nil {
- return fmt.Errorf("failed to write default config file: %w", err)
- }
- }
-
- cfg, err := config.Load()
- initialConfigValid := true
- if err != nil {
- initialConfigValid = false
- slog.Warn("Failed to load config (will require GUI configuration)", "error", err)
- cfg = &config.Config{
- Host: "127.0.0.1",
- Port: 3456,
- Logging: config.LoggingConfig{
- Level: "info",
- },
- OpenCodeGo: config.OpenCodeGoConfig{
- BaseURL: "https://opencode.ai/zen/go/v1/chat/completions",
- AnthropicBaseURL: "https://opencode.ai/zen/go/v1/messages",
- TimeoutMs: 300000,
- },
- OpenCodeZen: config.OpenCodeZenConfig{
- BaseURL: "https://opencode.ai/zen/v1/chat/completions",
- AnthropicBaseURL: "https://opencode.ai/zen/v1/messages",
- ResponsesBaseURL: "https://opencode.ai/zen/v1/responses",
- GeminiBaseURL: "https://opencode.ai/zen/v1/models",
- TimeoutMs: 300000,
- },
- }
- }
-
- if initialConfigValid && cfg.APIKey == "" && len(cfg.APIKeys) == 0 &&
- (cfg.OpenCodeGo.APIKey == "" || strings.Contains(cfg.OpenCodeGo.APIKey, "${")) &&
- (cfg.OpenCodeZen.APIKey == "" || strings.Contains(cfg.OpenCodeZen.APIKey, "${")) {
- initialConfigValid = false
- slog.Info("Config has no valid API keys set yet, waiting for GUI configuration")
- }
-
- atomic := config.NewAtomicConfig(cfg, config.ResolveConfigPath())
-
- var captureLogger *debug.CaptureLogger
- if cfg.Logging.DebugCapture != nil && cfg.Logging.DebugCapture.Enabled {
- storage, err := debug.NewStorage(*cfg.Logging.DebugCapture)
- if err != nil {
- return fmt.Errorf("failed to create debug storage: %w", err)
- }
- captureLogger = debug.NewCaptureLogger(storage, true)
- defer func() { _ = captureLogger.Close() }()
- }
-
- proxySrv, err := server.NewServer(atomic, captureLogger)
- if err != nil {
- return fmt.Errorf("create proxy server: %w", err)
- }
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- var startProxy func() error
- var stopProxy func() error
-
- sigCh := make(chan os.Signal, 1)
- signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
- go func() {
- <-sigCh
- slog.Info("Received signal, exiting...")
- if stopProxy != nil {
- _ = stopProxy()
- }
- cancel()
- tray.Quit()
- }()
-
- proxyErrCh := make(chan error, 1)
- var isProxyRunning bool
- var connectedToExisting bool
- var proxySrvMu sync.Mutex
- var guiSrv *gui.Server
-
- startProxy = func() error {
- proxySrvMu.Lock()
- defer proxySrvMu.Unlock()
-
- if isProxyRunning {
- return nil
- }
-
- currentCfg := atomic.Get()
- if currentCfg.APIKey == "" && len(currentCfg.APIKeys) == 0 &&
- (currentCfg.OpenCodeGo.APIKey == "" || strings.Contains(currentCfg.OpenCodeGo.APIKey, "${")) &&
- (currentCfg.OpenCodeZen.APIKey == "" || strings.Contains(currentCfg.OpenCodeZen.APIKey, "${")) {
- return fmt.Errorf("API Key is empty. Please set it in Settings first")
- }
-
- healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", currentCfg.Port)
- client := &http.Client{Timeout: 2 * time.Second}
- resp, probeErr := client.Get(healthURL)
- if probeErr == nil {
- _, _ = io.Copy(io.Discard, resp.Body)
- _ = resp.Body.Close()
- if resp.StatusCode == http.StatusOK {
- slog.Info("Existing proxy detected on port, connecting to it", "port", currentCfg.Port)
- isProxyRunning = true
- connectedToExisting = true
- if guiSrv != nil {
- guiSrv.SetProxyRunning(true)
- }
- tray.SetRunning(true)
- return nil
- }
- }
-
- isProxyRunning = true
- connectedToExisting = false
- if guiSrv != nil {
- guiSrv.SetProxyRunning(true)
- }
- tray.SetRunning(true)
-
- go func() {
- err := proxySrv.Start()
- proxySrvMu.Lock()
- isProxyRunning = false
- proxySrvMu.Unlock()
-
- if guiSrv != nil {
- guiSrv.SetProxyRunning(false)
- }
- tray.SetRunning(false)
-
- if err != nil && err != http.ErrServerClosed {
- slog.Error("proxy server stopped with error", "error", err)
- select {
- case proxyErrCh <- err:
- default:
- }
- }
- }()
- return nil
- }
-
- stopProxy = func() error {
- proxySrvMu.Lock()
- defer proxySrvMu.Unlock()
-
- if !isProxyRunning {
- return nil
- }
- wasConnected := connectedToExisting
- isProxyRunning = false
- connectedToExisting = false
- if guiSrv != nil {
- guiSrv.SetProxyRunning(false)
- }
- tray.SetRunning(false)
-
- if !wasConnected {
- shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer shutdownCancel()
- return proxySrv.Shutdown(shutdownCtx)
- }
- return nil
- }
-
- proxyInitiallyStarted := false
- if initialConfigValid {
- if err := startProxy(); err == nil {
- proxyInitiallyStarted = true
- } else {
- slog.Warn("Failed to auto-start proxy on boot", "error", err)
- }
- }
-
- guiSrv = gui.New(gui.Options{
- History: proxySrv.History,
- Metrics: proxySrv.Metrics(),
- AtomicConfig: atomic,
- ProxyPort: cfg.Port,
- StartProxy: startProxy,
- StopProxy: stopProxy,
- CatalogDir: resolveCatalogDir(configPath),
- CatalogSourceURL: cfg.Catalog.SourceURL,
- })
- guiSrv.SetProxyRunning(proxyInitiallyStarted)
-
- guiURL, err := guiSrv.Start(ctx)
- if err != nil {
- return fmt.Errorf("start gui server: %w", err)
- }
-
- globalGUIURL = guiURL
-
- autostartEnabled := false
- if home, err := os.UserHomeDir(); err == nil {
- plistPath := filepath.Join(home, "Library", "LaunchAgents", daemon.LaunchAgent+".plist")
- _, err = os.Stat(plistPath)
- autostartEnabled = (err == nil)
- }
-
- go func() {
- time.Sleep(500 * time.Millisecond)
- C.DispatchOpenWindow()
- }()
-
- tray.Run(tray.Callbacks{
- InitiallyRunning: proxyInitiallyStarted,
- InitiallyAutostart: autostartEnabled,
- OnOpen: func() {
- C.DispatchOpenWindow()
- },
- OnStart: func() {
- if err := startProxy(); err == nil {
- guiSrv.SetProxyRunning(true)
- tray.SetRunning(true)
- } else {
- guiSrv.SetProxyRunning(false)
- tray.SetRunning(false)
- }
- },
- OnStop: func() {
- _ = stopProxy()
- guiSrv.SetProxyRunning(false)
- tray.SetRunning(false)
- },
- OnAutostart: func(enabled bool) {
- if enabled {
- _ = daemon.EnableAutostart(configPath, atomic.Get().Port)
- } else {
- _ = daemon.DisableAutostart()
- }
- },
- OnQuit: func() {
- _ = stopProxy()
- cancel()
- },
- })
-
- return nil
- },
-}
-
-func addPlatformCommands(rootCmd *cobra.Command) {
- uiCmd.Flags().String("config", "", "Config file path")
- rootCmd.AddCommand(uiCmd)
-}
-
-func setupDefaultCommand() {
- if len(os.Args) == 1 {
- executable, err := os.Executable()
- if err == nil && strings.Contains(executable, ".app/Contents/MacOS") {
- os.Args = append(os.Args, "ui")
- }
- }
-}
diff --git a/cmd/routatic-proxy/ui_darwin_nocgo.go b/cmd/routatic-proxy/ui_darwin_nocgo.go
deleted file mode 100644
index 91136c33..00000000
--- a/cmd/routatic-proxy/ui_darwin_nocgo.go
+++ /dev/null
@@ -1,13 +0,0 @@
-//go:build darwin && !cgo
-
-package main
-
-import (
- "github.com/spf13/cobra"
-)
-
-func addPlatformCommands(rootCmd *cobra.Command) {
-}
-
-func setupDefaultCommand() {
-}
diff --git a/cmd/routatic-proxy/ui_linux_nocgo.go b/cmd/routatic-proxy/ui_linux_nocgo.go
deleted file mode 100644
index 6eb4e879..00000000
--- a/cmd/routatic-proxy/ui_linux_nocgo.go
+++ /dev/null
@@ -1,260 +0,0 @@
-//go:build linux
-
-package main
-
-import (
- "context"
- "fmt"
- "io"
- "log/slog"
- "net/http"
- "os"
- "os/exec"
- "os/signal"
- "path/filepath"
- "strings"
- "sync"
- "syscall"
- "time"
-
- "github.com/routatic/proxy/internal/config"
- "github.com/routatic/proxy/internal/debug"
- "github.com/routatic/proxy/internal/gui"
- "github.com/routatic/proxy/internal/server"
- "github.com/spf13/cobra"
-)
-
-func openBrowser(target string) error {
- cmd := exec.Command("xdg-open", target)
- cmd.Stdin = nil
- cmd.Stdout = nil
- cmd.Stderr = nil
- return cmd.Start()
-}
-
-// uiCmd is the "routatic-proxy ui" command (Linux, no CGO / no tray).
-// It starts the proxy in the same process, opens the dashboard in the default
-// browser, and waits for SIGINT/SIGTERM.
-var uiCmd = &cobra.Command{
- Use: "ui",
- Short: "Launch GUI dashboard",
- Long: `Start the proxy server and open the graphical dashboard in your browser.
-The proxy runs in the background; closing the browser leaves it running.
-Press Ctrl+C to stop.`,
- RunE: func(cmd *cobra.Command, args []string) error {
- // ── 1. Load config ──────────────────────────────────────────
- configPath, _ := cmd.Flags().GetString("config")
- if configPath == "" {
- configPath = config.ResolveConfigPath()
- } else {
- _ = os.Setenv("ROUTATIC_PROXY_CONFIG", configPath)
- }
-
- if _, err := os.Stat(configPath); os.IsNotExist(err) {
- slog.Info("Config file not found, auto-initializing default config", "path", configPath)
- configDir := filepath.Dir(configPath)
- if err := os.MkdirAll(configDir, 0700); err != nil {
- return fmt.Errorf("failed to create config directory: %w", err)
- }
- if err := os.WriteFile(configPath, []byte(getDefaultConfig()), 0600); err != nil {
- return fmt.Errorf("failed to write default config file: %w", err)
- }
- }
-
- cfg, err := config.Load()
- initialConfigValid := true
- if err != nil {
- initialConfigValid = false
- slog.Warn("Failed to load config (will require GUI configuration)", "error", err)
- cfg = &config.Config{
- Host: "127.0.0.1", Port: 3456,
- Logging: config.LoggingConfig{Level: "info"},
- OpenCodeGo: config.OpenCodeGoConfig{
- BaseURL: "https://opencode.ai/zen/go/v1/chat/completions",
- AnthropicBaseURL: "https://opencode.ai/zen/go/v1/messages",
- TimeoutMs: 300000,
- },
- OpenCodeZen: config.OpenCodeZenConfig{
- BaseURL: "https://opencode.ai/zen/v1/chat/completions",
- AnthropicBaseURL: "https://opencode.ai/zen/v1/messages",
- ResponsesBaseURL: "https://opencode.ai/zen/v1/responses",
- GeminiBaseURL: "https://opencode.ai/zen/v1/models",
- TimeoutMs: 300000,
- },
- }
- }
-
- if initialConfigValid &&
- cfg.APIKey == "" && len(cfg.APIKeys) == 0 &&
- (cfg.OpenCodeGo.APIKey == "" || strings.Contains(cfg.OpenCodeGo.APIKey, "${")) &&
- (cfg.OpenCodeZen.APIKey == "" || strings.Contains(cfg.OpenCodeZen.APIKey, "${")) {
- initialConfigValid = false
- slog.Info("Config has no valid API keys set yet, waiting for GUI configuration")
- }
-
- atomic := config.NewAtomicConfig(cfg, config.ResolveConfigPath())
-
- // ── 2. Debug capture (optional) ─────────────────────────────
- var captureLogger *debug.CaptureLogger
- if cfg.Logging.DebugCapture != nil && cfg.Logging.DebugCapture.Enabled {
- storage, err := debug.NewStorage(*cfg.Logging.DebugCapture)
- if err != nil {
- return fmt.Errorf("failed to create debug storage: %w", err)
- }
- captureLogger = debug.NewCaptureLogger(storage, true)
- defer func() { _ = captureLogger.Close() }()
- }
-
- // ── 3. Create proxy server ──────────────────────────────────
- proxySrv, err := server.NewServer(atomic, captureLogger)
- if err != nil {
- return fmt.Errorf("create proxy server: %w", err)
- }
-
- // ── 4. Context + signals ────────────────────────────────────
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- var startProxy func() error
- var stopProxy func() error
-
- sigCh := make(chan os.Signal, 1)
- signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
- go func() {
- <-sigCh
- slog.Info("Received signal, exiting...")
- if stopProxy != nil {
- _ = stopProxy()
- }
- cancel()
- }()
-
- // ── 5. Start proxy ──────────────────────────────────────────
- var isProxyRunning bool
- var connectedToExisting bool
- var proxySrvMu sync.Mutex
- var guiSrv *gui.Server
-
- // Function to check and connect to existing proxy
- checkExistingProxy := func() bool {
- currentCfg := atomic.Get()
- healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", currentCfg.Port)
- client := &http.Client{Timeout: 2 * time.Second}
- resp, probeErr := client.Get(healthURL)
- if probeErr == nil {
- _, _ = io.Copy(io.Discard, resp.Body)
- _ = resp.Body.Close()
- if resp.StatusCode == http.StatusOK {
- slog.Info("Existing proxy detected on port, connecting to it", "port", currentCfg.Port)
- return true
- }
- }
- return false
- }
-
- // Check for existing proxy BEFORE creating GUI server
- connectedToExisting = checkExistingProxy()
- isProxyRunning = connectedToExisting
-
- startProxy = func() error {
- proxySrvMu.Lock()
- defer proxySrvMu.Unlock()
- if isProxyRunning {
- return nil
- }
- currentCfg := atomic.Get()
- if currentCfg.APIKey == "" && len(currentCfg.APIKeys) == 0 &&
- (currentCfg.OpenCodeGo.APIKey == "" || strings.Contains(currentCfg.OpenCodeGo.APIKey, "${")) &&
- (currentCfg.OpenCodeZen.APIKey == "" || strings.Contains(currentCfg.OpenCodeZen.APIKey, "${")) {
- return fmt.Errorf("API Key is empty. Please set it in Settings first")
- }
-
- isProxyRunning = true
- connectedToExisting = false
- if guiSrv != nil {
- guiSrv.SetProxyRunning(true)
- guiSrv.SetConnectedToExisting(false)
- }
- go func() {
- srvErr := proxySrv.Start()
- proxySrvMu.Lock()
- isProxyRunning = false
- proxySrvMu.Unlock()
- if guiSrv != nil {
- guiSrv.SetProxyRunning(false)
- }
- if srvErr != nil && srvErr != http.ErrServerClosed {
- slog.Error("proxy server stopped with error", "error", srvErr)
- }
- }()
- return nil
- }
-
- stopProxy = func() error {
- proxySrvMu.Lock()
- defer proxySrvMu.Unlock()
- if !isProxyRunning {
- return nil
- }
- wasConnected := connectedToExisting
- isProxyRunning = false
- connectedToExisting = false
- if guiSrv != nil {
- guiSrv.SetProxyRunning(false)
- guiSrv.SetConnectedToExisting(false)
- }
- if !wasConnected {
- shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer shutdownCancel()
- return proxySrv.Shutdown(shutdownCtx)
- }
- return nil
- }
-
- if initialConfigValid {
- if err := startProxy(); err != nil {
- slog.Warn("Failed to auto-start proxy on boot", "error", err)
- }
- }
-
- // ── 6. Start GUI HTTP server ────────────────────────────────
- guiSrv = gui.New(gui.Options{
- History: proxySrv.History,
- Metrics: proxySrv.Metrics(),
- AtomicConfig: atomic,
- ProxyPort: cfg.Port,
- StartProxy: startProxy,
- StopProxy: stopProxy,
- CatalogDir: resolveCatalogDir(configPath),
- CatalogSourceURL: cfg.Catalog.SourceURL,
- })
-
- // Set the connected flag now that guiSrv exists
- guiSrv.SetProxyRunning(isProxyRunning)
- guiSrv.SetConnectedToExisting(connectedToExisting)
-
- guiURL, err := guiSrv.Start(ctx)
- if err != nil {
- return fmt.Errorf("start gui server: %w", err)
- }
-
- // ── 7. Open browser ─────────────────────────────────────────
- slog.Info("Opening browser", "url", guiURL)
- if err := openBrowser(guiURL); err != nil {
- slog.Warn("Failed to open browser (xdg-open may not be available)", "error", err)
- fmt.Printf("\nDashboard URL: %s\n", guiURL)
- }
-
- // ── 8. Wait for signal ──────────────────────────────────────
- fmt.Println("\nPress Ctrl+C to stop the proxy and exit.")
- <-ctx.Done()
- return nil
- },
-}
-
-func addPlatformCommands(rootCmd *cobra.Command) {
- uiCmd.Flags().String("config", "", "Config file path")
- rootCmd.AddCommand(uiCmd)
-}
-
-func setupDefaultCommand() {}
diff --git a/cmd/routatic-proxy/ui_other.go b/cmd/routatic-proxy/ui_other.go
deleted file mode 100644
index 93a5dab2..00000000
--- a/cmd/routatic-proxy/ui_other.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build !linux && !darwin
-
-package main
-
-import "github.com/spf13/cobra"
-
-func addPlatformCommands(rootCmd *cobra.Command) {}
-
-func setupDefaultCommand() {}
diff --git a/cmd/routatic-proxy/update.go b/cmd/routatic-proxy/update.go
index 0d0a136a..bc94fa31 100644
--- a/cmd/routatic-proxy/update.go
+++ b/cmd/routatic-proxy/update.go
@@ -2,112 +2,76 @@ package main
import (
"fmt"
- "strings"
- "github.com/routatic/proxy/internal/daemon"
- "github.com/routatic/proxy/internal/updater"
+ "github.com/routatic/proxy/internal/update"
"github.com/spf13/cobra"
)
-// updateCmd returns the Cobra command that updates routatic-proxy to the
-// latest GitHub release.
-func updateCmd() *cobra.Command {
- var (
- checkOnly bool
- yes bool
- force bool
- skipChecksum bool
- )
-
- cmd := &cobra.Command{
- Use: "update",
- Short: "Update routatic-proxy to the latest release",
- Long: `Check GitHub for the latest routatic-proxy release and, if a newer
-version is available, download the matching asset for this OS/arch,
-verify its SHA256 checksum, and replace the running binary in place.
-
-A .old backup of the previous binary is written next to the running
-executable on every platform. On Windows the backup is scheduled for
-deletion after the process exits because the running executable is
-locked until then.
-
-If the current binary reports its version as "dev" (e.g. when built
-from source without a version tag) the command refuses to update
-unless --force is passed.`,
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := cmd.Context()
-
- info, err := updater.Check(ctx)
- if err != nil {
- return err
- }
-
- if checkOnly {
- needs, err := updater.NeedsUpdate(version, info.TagName, false)
- if err != nil {
- return err
- }
- if needs {
- fmt.Printf("Update available: %s -> %s\n", version, info.TagName)
- } else {
- fmt.Printf("Already up to date (%s)\n", version)
- }
- return nil
- }
-
- needs, err := updater.NeedsUpdate(version, info.TagName, force)
- if err != nil {
- return err
- }
- if !needs {
- fmt.Printf("Already up to date (%s)\n", version)
- return nil
- }
-
- if !yes {
- fmt.Printf("Update %s -> %s? [y/N] ", version, info.TagName)
- var resp string
- if _, err := fmt.Scanln(&resp); err != nil {
- return fmt.Errorf("aborted")
- }
- if strings.ToLower(strings.TrimSpace(resp)) != "y" {
- return fmt.Errorf("update cancelled")
- }
- }
-
- currentPath, err := daemon.FindBinary()
- if err != nil {
- return fmt.Errorf("cannot locate current binary: %w", err)
- }
-
- result, err := updater.Apply(ctx, updater.Options{
- CurrentVersion: version,
- CurrentBinaryPath: currentPath,
- Force: force,
- SkipChecksum: skipChecksum,
- })
- if err != nil {
- return err
- }
-
- if !result.Updated {
- fmt.Printf("Already up to date (%s)\n", version)
- return nil
- }
-
- fmt.Printf("Updated %s -> %s\n", result.OldVersion, result.NewVersion)
- fmt.Printf("New binary: %s\n", result.NewPath)
- if result.BackupPath != "" {
- fmt.Printf("Backup: %s\n", result.BackupPath)
- }
+var updateCmd = &cobra.Command{
+ Use: "update [check]",
+ Short: "Update routatic-proxy to the latest version",
+ Long: `Download and install the latest version of routatic-proxy.
+
+The update command respects your configured update channel (stable or beta).
+Use 'routatic-proxy update-channel' to switch between channels.
+
+Examples:
+ routatic-proxy update # Download and install latest version
+ routatic-proxy update check # Check for updates without installing`,
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ channel, err := update.GetChannel()
+ if err != nil {
+ return fmt.Errorf("failed to get update channel: %w", err)
+ }
+ fmt.Printf("Checking for updates on %s channel...\n", channel)
+
+ // Get current version (from build info or embedded)
+ currentVersion := version // from main.go
+ if currentVersion == "" {
+ currentVersion = "dev"
+ }
+
+ release, err := update.GetLatestRelease(string(channel))
+ if err != nil {
+ return fmt.Errorf("failed to check for updates: %w", err)
+ }
+
+ // Check if update is available
+ if release.TagName == currentVersion || release.TagName <= currentVersion {
+ fmt.Printf("You are already on the latest version (%s).\n", currentVersion)
return nil
- },
- }
+ }
- cmd.Flags().BoolVarP(&checkOnly, "check", "c", false, "Only check for updates; do not install")
- cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip the confirmation prompt")
- cmd.Flags().BoolVarP(&force, "force", "f", false, "Update even if already on the latest version (required when current version is 'dev')")
- cmd.Flags().BoolVar(&skipChecksum, "skip-checksum", false, "Skip SHA256 checksum verification of the downloaded asset")
+ fmt.Printf("New version available: %s (current: %s)\n", release.TagName, currentVersion)
- return cmd
+ // If just checking, stop here
+ if len(args) > 0 && args[0] == "check" {
+ fmt.Println("Run 'routatic-proxy update' to install.")
+ return nil
+ }
+
+ // Get download URL for current platform
+ url, filename, err := update.GetAssetURL(release)
+ if err != nil {
+ return fmt.Errorf("failed to find download for your platform: %w", err)
+ }
+
+ fmt.Printf("Downloading %s...\n", filename)
+
+ // Download and install
+ if err := update.DownloadAndInstall(url, filename); err != nil {
+ return fmt.Errorf("failed to install update: %w", err)
+ }
+
+ fmt.Printf("Successfully updated to %s!\n", release.TagName)
+ fmt.Println("Please restart routatic-proxy to use the new version.")
+
+ return nil
+ },
}
+
+// TODO: wire updateCmd into rootCmd when root command registration is centralized.
+// func init() {
+// rootCmd.AddCommand(updateCmd)
+// }
diff --git a/cmd/routatic-proxy/update_channel.go b/cmd/routatic-proxy/update_channel.go
new file mode 100644
index 00000000..78cb3a76
--- /dev/null
+++ b/cmd/routatic-proxy/update_channel.go
@@ -0,0 +1,58 @@
+package main
+
+import (
+ "fmt"
+
+ "github.com/routatic/proxy/internal/update"
+ "github.com/spf13/cobra"
+)
+
+var updateChannelCmd = &cobra.Command{
+ Use: "update-channel [stable|beta]",
+ Short: "Switch between stable and beta update channels",
+ Long: `Switch between update channels:
+
+ stable - Default channel with production releases (recommended for most users)
+ beta - Early access to beta releases with the latest features
+
+Examples:
+ routatic-proxy update-channel beta # Switch to beta channel
+ routatic-proxy update-channel stable # Switch back to stable channel
+ routatic-proxy update-channel # Show current channel`,
+ Args: cobra.MaximumNArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ // Show current channel
+ channel, err := update.GetChannel()
+ if err != nil {
+ return fmt.Errorf("failed to get current channel: %w", err)
+ }
+ fmt.Printf("Current update channel: %s\n", channel)
+ fmt.Println("\nTo switch channels:")
+ fmt.Println(" routatic-proxy update-channel stable # Production releases")
+ fmt.Println(" routatic-proxy update-channel beta # Beta releases")
+ return nil
+ }
+
+ channel := update.Channel(args[0])
+
+ if err := update.SetChannel(channel); err != nil {
+ return err
+ }
+
+ if channel == update.ChannelBeta {
+ fmt.Println("You will now receive beta releases when running 'routatic-proxy update'.")
+ fmt.Println("To switch back to stable releases, run: routatic-proxy update-channel stable")
+ } else {
+ fmt.Println("You will now receive stable (production) releases when running 'routatic-proxy update'.")
+ fmt.Println("To receive beta releases, run: routatic-proxy update-channel beta")
+ }
+
+ return nil
+ },
+}
+
+// TODO: wire updateChannelCmd into rootCmd when root command registration is centralized.
+// func init() {
+// rootCmd.AddCommand(updateChannelCmd)
+// }
diff --git a/docs/models.md b/docs/models.md
new file mode 100644
index 00000000..8a291ac8
--- /dev/null
+++ b/docs/models.md
@@ -0,0 +1,68 @@
+# 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 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.
diff --git a/docs/openrouter.md b/docs/openrouter.md
new file mode 100644
index 00000000..9dae51ab
--- /dev/null
+++ b/docs/openrouter.md
@@ -0,0 +1,396 @@
+# OpenRouter Provider
+
+[OpenRouter](https://openrouter.ai) is a unified API for 200+ LLMs from OpenAI, Anthropic, Google, Meta, Mistral, and other leading AI providers. It provides a single endpoint for accessing models from multiple vendors without managing separate API keys and integrations for each provider.
+
+## Overview
+
+### What is OpenRouter?
+
+OpenRouter acts as a universal gateway to the AI model ecosystem. Instead of maintaining separate accounts and API keys for OpenAI, Anthropic, Google, and dozens of other providers, you use a single OpenRouter API key to access them all. OpenRouter handles the routing, normalization, and billing.
+
+### Benefits
+
+- **Unified API**: One endpoint, one authentication method for 200+ models
+- **Automatic failover**: If a provider is down, requests can route to alternatives
+- **Standardized pricing**: Clear per-token costs across all providers
+- **Model exploration**: Easily experiment with new models without new integrations
+- **OpenAI-compatible format**: Works with existing OpenAI SDKs and tools
+- **No code changes**: Add new models via configuration only
+
+## Getting Started
+
+### 1. Sign Up and Get API Key
+
+1. Sign up at [openrouter.ai](https://openrouter.ai)
+2. Generate an API key at [https://openrouter.ai/keys](https://openrouter.ai/keys)
+3. Add funds to your account (pay-as-you-go pricing)
+
+### 2. Configure Environment Variables
+
+Set the environment variable:
+
+```bash
+export ROUTATIC_PROXY_OPENROUTER_API_KEY=sk-or-v1-your-key-here
+```
+
+For key rotation or load balancing across multiple keys, use a comma-separated list:
+
+```bash
+export ROUTATIC_PROXY_OPENROUTER_API_KEYS=key-1,key-2,key-3
+```
+
+### 3. Enable in Config
+
+Add the `openrouter` provider to your `~/.config/routatic-proxy/config.json`:
+
+```json
+{
+ "providers": {
+ "openrouter": {
+ "enabled": true,
+ "api_key": "${ROUTATIC_PROXY_OPENROUTER_API_KEY}",
+ "base_url": "https://openrouter.ai/api/v1"
+ }
+ }
+}
+```
+
+## Configuration
+
+### Configuration Schema
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `name` | `string` | No | Provider display name (defaults to "openrouter") |
+| `base_url` | `string` | No | API endpoint base URL. Default: `https://openrouter.ai/api/v1` |
+| `api_key` | `string` | Yes* | Single API key for authentication. Required if `api_keys` not set |
+| `api_keys` | `string[]` | Yes* | Multiple API keys for round-robin rotation. Required if `api_key` not set |
+| `enabled` | `bool` | No | Whether this provider is active. Default: `true` |
+| `timeout_ms` | `int` | No | Request timeout in milliseconds. Default: `300000` (5 minutes) |
+| `stream_timeout_ms` | `int` | No | Per-chunk timeout during streaming. Default: `60000` (1 minute) |
+
+*At least one of `api_key` or `api_keys` must be configured.
+
+### Environment Variables
+
+| Variable | Description | Precedence |
+|----------|-------------|------------|
+| `ROUTATIC_PROXY_OPENROUTER_API_KEY` | Single API key override | Highest |
+| `ROUTATIC_PROXY_OPENROUTER_API_KEYS` | Comma-separated keys for round-robin | Highest |
+| `ROUTATIC_PROXY_OPENROUTER_BASE_URL` | Custom base URL override | Highest |
+
+Environment variables take precedence over config file values. Config values support `${VAR}` interpolation.
+
+Precedence order: `*_API_KEYS` → `*_API_KEY` → config file `api_keys` → config file `api_key`
+
+## Configuration Examples
+
+### Single-Key Setup
+
+```json
+{
+ "providers": {
+ "openrouter": {
+ "enabled": true,
+ "api_key": "sk-or-v1-xxxxxxxxxxxxxxxxxxxxxxxx"
+ }
+ }
+}
+```
+
+### Multi-Key Round-Robin
+
+For load balancing across multiple API keys:
+
+```json
+{
+ "providers": {
+ "openrouter": {
+ "enabled": true,
+ "api_keys": [
+ "sk-or-v1-key-1",
+ "sk-or-v1-key-2",
+ "sk-or-v1-key-3"
+ ]
+ }
+ }
+}
+```
+
+### Custom Base URL
+
+For enterprise/self-hosted OpenRouter deployments:
+
+```json
+{
+ "providers": {
+ "openrouter": {
+ "enabled": true,
+ "base_url": "https://openrouter.mycompany.com/api/v1",
+ "api_key": "${OPENROUTER_API_KEY}"
+ }
+ }
+}
+```
+
+### Complete Configuration with Models
+
+```json
+{
+ "providers": {
+ "openrouter": {
+ "enabled": true,
+ "api_key": "${ROUTATIC_PROXY_OPENROUTER_API_KEY}",
+ "base_url": "https://openrouter.ai/api/v1",
+ "timeout_ms": 300000,
+ "stream_timeout_ms": 60000
+ }
+ },
+ "models": {
+ "openrouter/openai/gpt-4o": {
+ "enabled": true,
+ "display_name": "GPT-4o (via OpenRouter)"
+ },
+ "openrouter/anthropic/claude-3.5-sonnet": {
+ "enabled": true,
+ "display_name": "Claude 3.5 Sonnet (via OpenRouter)"
+ },
+ "openrouter/anthropic/claude-3-opus": {
+ "enabled": true,
+ "display_name": "Claude 3 Opus (via OpenRouter)"
+ },
+ "openrouter/anthropic/claude-3.5-haiku": {
+ "enabled": true,
+ "display_name": "Claude 3.5 Haiku (via OpenRouter)"
+ },
+ "openrouter/google/gemini-2.0-flash-exp": {
+ "enabled": true,
+ "display_name": "Gemini 2.0 Flash (via OpenRouter)"
+ },
+ "openrouter/google/gemini-pro-1.5": {
+ "enabled": true,
+ "display_name": "Gemini 1.5 Pro (via OpenRouter)"
+ },
+ "openrouter/meta-llama/llama-3.3-70b-instruct": {
+ "enabled": true,
+ "display_name": "Llama 3.3 70B (via OpenRouter)"
+ },
+ "openrouter/meta-llama/llama-3.1-405b": {
+ "enabled": true,
+ "display_name": "Llama 3.1 405B (via OpenRouter)"
+ },
+ "openrouter/mistralai/mistral-large": {
+ "enabled": true,
+ "display_name": "Mistral Large (via OpenRouter)"
+ },
+ "openrouter/deepseek/deepseek-chat": {
+ "enabled": true,
+ "display_name": "DeepSeek V3 (via OpenRouter)"
+ }
+ }
+}
+```
+
+## Cost-Based Routing Integration
+
+OpenRouter works seamlessly with `cost_routing`. Use `penalty_per_provider` to adjust effective costs:
+
+```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
+ }
+ }
+}
+```
+
+Penalties are additive to the raw model cost. Example: a model costing $0.10/1M tokens on OpenRouter with a 0.02 penalty has effective cost $0.12/1M tokens. Use this to bias routing preferences without excluding providers entirely.
+
+### Applying Cost Penalty to OpenRouter
+
+When using `cost_routing`, you can apply a penalty to OpenRouter requests to account for routing overhead or prefer direct providers when costs are similar:
+
+```json
+{
+ "cost_routing": {
+ "enabled": true,
+ "prefer_providers": ["opencode-go", "openrouter"],
+ "penalty_per_provider": {
+ "openrouter": 0.05
+ }
+ }
+}
+```
+
+This adds a small cost penalty (e.g., 5 cents per million tokens) when selecting OpenRouter models, helping the router prefer direct providers when cost is comparable.
+
+## Model Selection and Naming Convention
+
+### Provider/Model Format
+
+OpenRouter uses the `provider/model-name` format. Models are referenced using the `openrouter/` prefix followed by the provider and model name:
+
+```
+openrouter/{provider}/{model-name}
+```
+
+Examples:
+- `openrouter/openai/gpt-4o`
+- `openrouter/anthropic/claude-3.5-sonnet`
+- `openrouter/google/gemini-2.0-flash-exp`
+- `openrouter/meta-llama/llama-3.3-70b-instruct`
+
+### Model Resolution via Catalog
+
+Models are referenced using the `provider/model-name` pattern. OpenRouter models use the `openrouter/` prefix:
+
+```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
+ }
+ }
+}
+```
+
+The `model_id` in your config must match OpenRouter's model identifier exactly.
+
+### Discovering Models
+
+1. Visit [openrouter.ai/models](https://openrouter.ai/models) for the complete model list
+2. Use the `routatic-proxy models` command to see cached catalog entries
+3. Check the [OpenRouter API docs](https://openrouter.ai/docs) for pricing and context limits
+
+## Model Examples
+
+| Model Key | Provider | Description | Best For |
+|-----------|----------|-------------|----------|
+| `openai/gpt-4o` | OpenAI | Latest GPT-4o multimodal model | General purpose, vision tasks |
+| `openai/o1` | OpenAI | Reasoning model (o1) | Complex reasoning, math, coding |
+| `openai/gpt-4.5-preview` | OpenAI | GPT-4.5 preview | Advanced reasoning, research |
+| `anthropic/claude-3.5-sonnet` | Anthropic | Claude 3.5 Sonnet | Coding, analysis, writing |
+| `anthropic/claude-3-opus` | Anthropic | Claude 3 Opus | Most capable Anthropic model |
+| `anthropic/claude-3.5-haiku` | Anthropic | Claude 3.5 Haiku | Fast, cost-effective tasks |
+| `anthropic/claude-opus-4` | Anthropic | Claude Opus 4 | Deep reasoning, coding |
+| `google/gemini-2.0-flash-exp` | Google | Gemini 2.0 Flash (experimental) | Low latency, high throughput |
+| `google/gemini-pro-1.5` | Google | Gemini 1.5 Pro | Long context (up to 2M tokens) |
+| `google/gemini-2.5-pro-preview-07-11` | Google | Gemini 2.5 Pro | Advanced multimodal tasks |
+| `meta-llama/llama-3.3-70b-instruct` | Meta | Llama 3.3 70B | Open source, self-hostable |
+| `meta-llama/llama-3.1-405b` | Meta | Llama 3.1 405B | Largest open source model |
+| `mistralai/mistral-large` | Mistral | Mistral Large | Strong multilingual performance |
+| `mistralai/mistral-medium` | Mistral | Mistral Medium | Balanced performance/cost |
+| `mistralai/mistral-small` | Mistral | Mistral Small | Fast, efficient tasks |
+| `deepseek/deepseek-chat` | DeepSeek | DeepSeek V3 | Strong reasoning, coding |
+| `deepseek/deepseek-r1` | DeepSeek | DeepSeek R1 | Reasoning, step-by-step |
+| `perplexity/sonar-reasoning` | Perplexity | Sonar Reasoning | Research, citations |
+
+See the full catalog at [https://openrouter.ai/models](https://openrouter.ai/models).
+
+## Use Cases
+
+### Accessing Specific Models
+
+Use OpenRouter when you need models not available on other providers:
+
+```json
+{
+ "models": {
+ "complex": {
+ "provider": "openrouter",
+ "model_id": "anthropic/claude-opus-4",
+ "temperature": 0.7,
+ "max_tokens": 8192,
+ "reasoning_effort": "max"
+ }
+ }
+}
+```
+
+### Fallback Chains
+
+Include OpenRouter as a fallback when primary providers fail:
+
+```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 Optimization
+
+Use `cost_routing` with provider penalties to automatically select the cheapest available model:
+
+```json
+{
+ "cost_routing": {
+ "enabled": true,
+ "prefer_providers": ["openrouter"],
+ "penalty_per_provider": {
+ "openrouter": -0.01
+ }
+ }
+}
+```
+
+## Official Documentation
+
+- **API Reference**: [https://openrouter.ai/docs](https://openrouter.ai/docs)
+- **OpenAI Compatibility**: [https://openrouter.ai/docs#openai-compatibility](https://openrouter.ai/docs#openai-compatibility)
+- **Provider Routing**: [https://openrouter.ai/docs#provider-routing](https://openrouter.ai/docs#provider-routing)
+- **Models Catalog**: [https://openrouter.ai/models](https://openrouter.ai/models)
+
+## Catalog Resolution Details
+
+Resolution functions in `internal/catalog/resolve.go` extract the provider from the key prefix. For OpenRouter models:
+
+- `ResolvedModel.ModelID` is the model name only (without provider prefix)
+- `ResolvedModel.CanonicalName` is the full key (e.g., `openrouter/anthropic/claude-opus-4`)
+
+The catalog schema for models includes:
+
+| Field | Description |
+|-------|-------------|
+| `id` | Full key (matches the map key) |
+| `name` | Display name |
+| `limit.context` | Context window size |
+| `rates.input` | Cost per million input tokens |
+| `rates.output` | Cost per million output tokens |
+| `tool_call` | Whether tools are supported |
+| `modalities.input` | Input types (`["text"]`, `["text", "image"]`) |
+| `modalities.output` | Output types (`["text"]`, `["text", "image"]`) |
+| `reasoning` | Whether reasoning mode is supported |
+
+For streaming, the router may downgrade to faster models for better TTFT (time to first token).
+
+---
+
+**Note**: OpenRouter models use the OpenAI Chat Completions API format. The proxy automatically handles request/response transformation between Anthropic and OpenAI formats.
diff --git a/go.mod b/go.mod
index 8fe8cc44..05cbcd1d 100644
--- a/go.mod
+++ b/go.mod
@@ -28,6 +28,7 @@ require (
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.5 // indirect
+ golang.org/x/mod v0.38.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/go.sum b/go.sum
index 75144c20..8838ad8f 100644
--- a/go.sum
+++ b/go.sum
@@ -56,6 +56,8 @@ github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
+golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go
index f83074ad..04c76ede 100644
--- a/internal/buildinfo/buildinfo.go
+++ b/internal/buildinfo/buildinfo.go
@@ -1,18 +1,75 @@
package buildinfo
-import "os"
+import (
+ "os"
+ "path/filepath"
+ "runtime/debug"
+ "strconv"
+)
-var Version = "dev"
-var BuildTime = "unknown"
+var (
+ // Version is the semantic version. Set via ldflags: -X github.com/routatic/proxy/internal/buildinfo.Version=vX.Y.Z
+ Version = "dev"
+ // Commit is the git commit SHA. Set via ldflags: -X github.com/routatic/proxy/internal/buildinfo.Commit=
+ Commit = "none"
+ // Date is the build timestamp (alias for BuildTime for compatibility).
+ Date = "unknown"
+ // BuildTime is the build timestamp. Set via ldflags.
+ BuildTime = "unknown"
+)
+func init() {
+ // If built with `go install` or module-aware build and no ldflags provided,
+ // try to extract version info from the embedded build metadata.
+ if info, ok := debug.ReadBuildInfo(); ok {
+ if Version == "dev" {
+ if info.Main.Version != "" && info.Main.Version != "(devel)" {
+ Version = info.Main.Version
+ }
+ }
+ // Capture VCS info if present (Go 1.18+)
+ for _, s := range info.Settings {
+ switch s.Key {
+ case "vcs.revision":
+ if Commit == "none" && s.Value != "" {
+ Commit = s.Value
+ }
+ case "vcs.time":
+ if BuildTime == "unknown" && s.Value != "" {
+ BuildTime = s.Value
+ if Date == "unknown" {
+ Date = s.Value
+ }
+ }
+ }
+ }
+ }
+}
+
+// PID returns the current process ID.
+func PID() int {
+ return os.Getpid()
+}
+
+// PIDString returns the current process ID as a string.
+func PIDString() string {
+ return strconv.Itoa(os.Getpid())
+}
+
+// BinaryPath returns the absolute path to the running binary.
func BinaryPath() string {
- path, err := os.Executable()
+ execPath, err := os.Executable()
if err != nil {
return "unknown"
}
- return path
+ absPath, err := filepath.Abs(execPath)
+ if err != nil {
+ return execPath
+ }
+ return absPath
}
-func PID() int {
- return os.Getpid()
+// String returns a human-readable build info summary.
+func String() string {
+ return Version + " (" + Commit + ") built at " + Date
}
diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go
new file mode 100644
index 00000000..ae664546
--- /dev/null
+++ b/internal/buildinfo/buildinfo_test.go
@@ -0,0 +1,63 @@
+package buildinfo
+
+import (
+ "os"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+func TestVersionNotEmpty(t *testing.T) {
+ if Version == "" {
+ t.Error("Version must not be empty")
+ }
+}
+
+func TestDateNotEmpty(t *testing.T) {
+ if Date == "" {
+ t.Error("Date must not be empty")
+ }
+}
+
+func TestBinaryPathReturnsSomething(t *testing.T) {
+ p := BinaryPath()
+ if p == "" {
+ t.Error("BinaryPath() returned empty string")
+ }
+}
+
+func TestPIDReturnsCurrentProcess(t *testing.T) {
+ pid := PID()
+ if pid <= 0 {
+ t.Errorf("PID() returned non-positive value: %d", pid)
+ }
+ if pid != os.Getpid() {
+ t.Logf("PID()=%d current=%d (may differ in some sandboxed environments)", pid, os.Getpid())
+ }
+}
+
+func TestPIDStringMatchesPID(t *testing.T) {
+ if PIDString() != strconv.Itoa(PID()) {
+ t.Error("PIDString() should equal strconv.Itoa(PID())")
+ }
+}
+
+func TestStringContainsAllFields(t *testing.T) {
+ s := String()
+ if !strings.Contains(s, Version) {
+ t.Errorf("String() should contain Version; got %q", s)
+ }
+ if !strings.Contains(s, Commit) {
+ t.Errorf("String() should contain Commit; got %q", s)
+ }
+ if !strings.Contains(s, Date) {
+ t.Errorf("String() should contain Date; got %q", s)
+ }
+}
+
+func TestInitDoesNotPanic(t *testing.T) {
+ // init() has already run; just ensure the package level vars are sane.
+ if Version == "" || Commit == "" || Date == "" {
+ t.Fatal("expected non-empty build info after package init")
+ }
+}
diff --git a/internal/catalog/migrate.go b/internal/catalog/migrate.go
index a857ece4..dd903936 100644
--- a/internal/catalog/migrate.go
+++ b/internal/catalog/migrate.go
@@ -37,27 +37,12 @@ func MigrateFromJSON(ctx context.Context, db *storage.Database, jsonPath string)
providers := make([]storage.ProviderRecord, 0, len(idx.Providers))
for name, p := range idx.Providers {
- providers = append(providers, storage.ProviderRecord{
- Name: name,
- BaseURL: p.BaseURL,
- APIKey: p.APIKey,
- Enabled: p.Enabled,
- AnthropicToolsDisabled: p.AnthropicToolsDisabled,
- })
+ providers = append(providers, providerToStorageRecord(name, p))
}
models := make([]storage.ModelRecord, 0, len(idx.Models))
for key, m := range idx.Models {
- models = append(models, storage.ModelRecord{
- ID: key,
- Name: m.Name,
- Reasoning: m.Reasoning,
- ToolCall: m.ToolCall,
- Vision: m.SupportsVision(),
- ContextWindow: m.ContextWindow(),
- CostInput: m.CostInputPerM(),
- CostOutput: m.CostOutputPerM(),
- })
+ models = append(models, modelToStorageRecord(key, m))
}
if err := repo.UpsertBatch(ctx, providers, models); err != nil {
@@ -92,30 +77,8 @@ func ExportJSON(ctx context.Context, db *storage.Database, jsonPath string) erro
}
for key, m := range idx.Models {
- model := Model{
- ID: ModelNameFromKey(key),
- Name: m.Name,
- Reasoning: m.Reasoning,
- ToolCall: m.ToolCall,
- }
-
- if m.Vision {
- model.Modalities.Input = []string{"text", "image"}
- } else {
- model.Modalities.Input = []string{"text"}
- }
- model.Modalities.Output = []string{"text"}
-
- if m.Limit != nil {
- model.Limit = &Limit{Context: m.Limit.Context}
- }
- if m.Rates != nil {
- model.Rates = &Rates{
- Input: m.Rates.Input,
- Output: m.Rates.Output,
- }
- }
-
+ model := storageModelToCatalogModel(m)
+ model.ID = ModelNameFromKey(key)
catalog.Models[key] = model
}
@@ -147,30 +110,8 @@ func LoadFromSQLite(ctx context.Context, db *storage.Database) (*IndexedCatalog,
}
for key, m := range storageIdx.Models {
- model := Model{
- ID: ModelNameFromKey(key),
- Name: m.Name,
- Reasoning: m.Reasoning,
- ToolCall: m.ToolCall,
- }
-
- if m.Vision {
- model.Modalities.Input = []string{"text", "image"}
- } else {
- model.Modalities.Input = []string{"text"}
- }
- model.Modalities.Output = []string{"text"}
-
- if m.Limit != nil {
- model.Limit = &Limit{Context: m.Limit.Context}
- }
- if m.Rates != nil {
- model.Rates = &Rates{
- Input: m.Rates.Input,
- Output: m.Rates.Output,
- }
- }
-
+ model := storageModelToCatalogModel(m)
+ model.ID = ModelNameFromKey(key)
cat.Models[key] = model
}
@@ -182,24 +123,7 @@ func LoadFromSQLite(ctx context.Context, db *storage.Database) (*IndexedCatalog,
for prov, models := range storageIdx.ProviderModels {
converted := make([]Model, len(models))
for i, m := range models {
- converted[i] = Model{
- ID: m.ID,
- Name: m.Name,
- Reasoning: m.Reasoning,
- ToolCall: m.ToolCall,
- }
- if m.Vision {
- converted[i].Modalities.Input = []string{"text", "image"}
- } else {
- converted[i].Modalities.Input = []string{"text"}
- }
- converted[i].Modalities.Output = []string{"text"}
- if m.Limit != nil {
- converted[i].Limit = &Limit{Context: m.Limit.Context}
- }
- if m.Rates != nil {
- converted[i].Rates = &Rates{Input: m.Rates.Input, Output: m.Rates.Output}
- }
+ converted[i] = storageModelToCatalogModel(m)
}
idx.ProviderModels[prov] = converted
}
@@ -207,82 +131,74 @@ func LoadFromSQLite(ctx context.Context, db *storage.Database) (*IndexedCatalog,
return idx, nil
}
-// SyncToSQLite downloads the catalog from sourceURL and imports it to SQLite.
-func SyncToSQLite(ctx context.Context, db *storage.Database, sourceURL string) error {
+// syncToSQLite is the shared core that fetches, parses, and upserts the
+// catalog into SQLite. Returns the number of providers and models imported.
+func syncToSQLite(ctx context.Context, db *storage.Database, sourceURL string) (int, int, error) {
if sourceURL == "" {
- return fmt.Errorf("source URL is required")
+ return 0, 0, fmt.Errorf("source URL is required")
}
if db == nil {
- return fmt.Errorf("database is required")
+ return 0, 0, fmt.Errorf("database is required")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
- return fmt.Errorf("build request: %w", err)
+ return 0, 0, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
- return fmt.Errorf("fetch catalog: %w", err)
+ return 0, 0, fmt.Errorf("fetch catalog: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode)
+ return 0, 0, fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode)
}
limited := http.MaxBytesReader(nil, resp.Body, maxCatalogBytes)
body, err := io.ReadAll(limited)
if err != nil {
- return fmt.Errorf("read catalog: %w", err)
+ return 0, 0, fmt.Errorf("read catalog: %w", err)
}
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
- return fmt.Errorf("parse catalog: %w", err)
+ return 0, 0, fmt.Errorf("parse catalog: %w", err)
}
if env.Models == nil || env.Providers == nil {
- return fmt.Errorf("catalog must contain models and providers objects")
+ return 0, 0, fmt.Errorf("catalog must contain models and providers objects")
}
var catalog Catalog
if err := json.Unmarshal(body, &catalog); err != nil {
- return fmt.Errorf("parse catalog contents: %w", err)
+ return 0, 0, fmt.Errorf("parse catalog contents: %w", err)
}
providers := make([]storage.ProviderRecord, 0, len(catalog.Providers))
for name, p := range catalog.Providers {
- providers = append(providers, storage.ProviderRecord{
- Name: name,
- BaseURL: p.BaseURL,
- APIKey: p.APIKey,
- Enabled: p.Enabled,
- AnthropicToolsDisabled: p.AnthropicToolsDisabled,
- })
+ providers = append(providers, providerToStorageRecord(name, p))
}
models := make([]storage.ModelRecord, 0, len(catalog.Models))
for key, m := range catalog.Models {
- models = append(models, storage.ModelRecord{
- ID: key,
- Name: m.Name,
- Reasoning: m.Reasoning,
- ToolCall: m.ToolCall,
- Vision: m.SupportsVision(),
- ContextWindow: m.ContextWindow(),
- CostInput: m.CostInputPerM(),
- CostOutput: m.CostOutputPerM(),
- })
+ models = append(models, modelToStorageRecord(key, m))
}
repo := storage.NewCatalogRepo(db)
if err := repo.UpsertBatch(ctx, providers, models); err != nil {
- return fmt.Errorf("upsert catalog: %w", err)
+ return 0, 0, fmt.Errorf("upsert catalog: %w", err)
}
- return nil
+ return len(providers), len(models), nil
+}
+
+// SyncToSQLite downloads the catalog from sourceURL and imports it to SQLite.
+func SyncToSQLite(ctx context.Context, db *storage.Database, sourceURL string) error {
+ _, _, err := syncToSQLite(ctx, db, sourceURL)
+ return err
}
// SyncStats holds statistics from a catalog sync operation.
@@ -295,83 +211,61 @@ type SyncStats struct {
// SyncToSQLiteWithStats downloads the catalog and returns sync statistics.
func SyncToSQLiteWithStats(ctx context.Context, db *storage.Database, sourceURL string) (*SyncStats, error) {
start := time.Now()
-
- if sourceURL == "" {
- return nil, fmt.Errorf("source URL is required")
- }
- if db == nil {
- return nil, fmt.Errorf("database is required")
- }
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
- if err != nil {
- return nil, fmt.Errorf("build request: %w", err)
- }
- req.Header.Set("Accept", "application/json")
-
- client := &http.Client{Timeout: 30 * time.Second}
- resp, err := client.Do(req)
+ providers, models, err := syncToSQLite(ctx, db, sourceURL)
if err != nil {
- return nil, fmt.Errorf("fetch catalog: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode)
- }
-
- limited := http.MaxBytesReader(nil, resp.Body, maxCatalogBytes)
- body, err := io.ReadAll(limited)
- if err != nil {
- return nil, fmt.Errorf("read catalog: %w", err)
- }
-
- var env envelope
- if err := json.Unmarshal(body, &env); err != nil {
- return nil, fmt.Errorf("parse catalog: %w", err)
- }
- if env.Models == nil || env.Providers == nil {
- return nil, fmt.Errorf("catalog must contain models and providers objects")
- }
-
- var catalog Catalog
- if err := json.Unmarshal(body, &catalog); err != nil {
- return nil, fmt.Errorf("parse catalog contents: %w", err)
+ return nil, err
}
+ return &SyncStats{
+ Providers: providers,
+ Models: models,
+ Duration: time.Since(start),
+ }, nil
+}
- providers := make([]storage.ProviderRecord, 0, len(catalog.Providers))
- for name, p := range catalog.Providers {
- providers = append(providers, storage.ProviderRecord{
- Name: name,
- BaseURL: p.BaseURL,
- APIKey: p.APIKey,
- Enabled: p.Enabled,
- AnthropicToolsDisabled: p.AnthropicToolsDisabled,
- })
+func providerToStorageRecord(name string, p Provider) storage.ProviderRecord {
+ return storage.ProviderRecord{
+ Name: name,
+ BaseURL: p.BaseURL,
+ APIKey: p.APIKey,
+ Enabled: p.Enabled,
+ AnthropicToolsDisabled: p.AnthropicToolsDisabled,
}
+}
- models := make([]storage.ModelRecord, 0, len(catalog.Models))
- for key, m := range catalog.Models {
- models = append(models, storage.ModelRecord{
- ID: key,
- Name: m.Name,
- Reasoning: m.Reasoning,
- ToolCall: m.ToolCall,
- Vision: m.SupportsVision(),
- ContextWindow: m.ContextWindow(),
- CostInput: m.CostInputPerM(),
- CostOutput: m.CostOutputPerM(),
- })
+func modelToStorageRecord(key string, m Model) storage.ModelRecord {
+ return storage.ModelRecord{
+ ID: key,
+ Name: m.Name,
+ Reasoning: m.Reasoning,
+ ToolCall: m.ToolCall,
+ Vision: m.SupportsVision(),
+ ContextWindow: m.ContextWindow(),
+ CostInput: m.CostInputPerM(),
+ CostOutput: m.CostOutputPerM(),
}
+}
- repo := storage.NewCatalogRepo(db)
- if err := repo.UpsertBatch(ctx, providers, models); err != nil {
- return nil, fmt.Errorf("upsert catalog: %w", err)
+func storageModelToCatalogModel(m storage.Model) Model {
+ model := Model{
+ ID: m.ID,
+ Name: m.Name,
+ Reasoning: m.Reasoning,
+ ToolCall: m.ToolCall,
+ }
+ if m.Vision {
+ model.Modalities.Input = []string{"text", "image"}
+ } else {
+ model.Modalities.Input = []string{"text"}
+ }
+ model.Modalities.Output = []string{"text"}
+ if m.Limit != nil {
+ model.Limit = &Limit{Context: m.Limit.Context}
+ }
+ if m.Rates != nil {
+ model.Rates = &Rates{
+ Input: m.Rates.Input,
+ Output: m.Rates.Output,
+ }
}
-
- return &SyncStats{
- Providers: len(providers),
- Models: len(models),
- Duration: time.Since(start),
- }, nil
+ return model
}
diff --git a/internal/config/config.go b/internal/config/config.go
index 9db65f49..258d4af8 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -30,6 +30,7 @@ type Config struct {
Debug DebugConfig `json:"debug"`
Catalog CatalogConfig `json:"catalog"`
Storage *StorageConfig `json:"storage,omitempty"`
+ UpdateChannel string `json:"update_channel,omitempty"`
}
// CostRoutingConfig controls cost-aware model selection.
diff --git a/internal/daemon/autostart_darwin.go b/internal/daemon/autostart_darwin.go
index 2877a757..1367cc42 100644
--- a/internal/daemon/autostart_darwin.go
+++ b/internal/daemon/autostart_darwin.go
@@ -22,7 +22,7 @@ const plistTemplate = `
ProgramArguments
{{.BinaryPath}}
- serve
+ start
--background
{{- if .ConfigPath}}
--config
diff --git a/internal/daemon/autostart_linux.go b/internal/daemon/autostart_linux.go
index d409733d..9fbdba80 100644
--- a/internal/daemon/autostart_linux.go
+++ b/internal/daemon/autostart_linux.go
@@ -13,7 +13,7 @@ const desktopFileTemplate = `[Desktop Entry]
Type=Application
Name={{.AppName}}
Comment=Start {{.AppName}} on login
-Exec="{{.BinaryPath}}" serve --background{{- if .ConfigPath}} --config "{{.ConfigPath}}"{{- end}}{{- if .Port}} --port {{.Port}}{{- end}}
+Exec="{{.BinaryPath}}" start --background{{- if .ConfigPath}} --config "{{.ConfigPath}}"{{- end}}{{- if .Port}} --port {{.Port}}{{- end}}
Hidden=false
NoDisplay=false
X-GNOME-Autostart-enabled=true
diff --git a/internal/daemon/autostart_windows.go b/internal/daemon/autostart_windows.go
index 5e0340ac..b1a02136 100644
--- a/internal/daemon/autostart_windows.go
+++ b/internal/daemon/autostart_windows.go
@@ -18,7 +18,7 @@ const (
)
func buildAutostartArgs(configPath string, port int) string {
- args := "serve --background"
+ args := "start --background"
if configPath != "" {
args += ` --config "` + configPath + `"`
}
diff --git a/internal/daemon/background.go b/internal/daemon/background.go
index c9b1ac2e..6e4e78e9 100644
--- a/internal/daemon/background.go
+++ b/internal/daemon/background.go
@@ -8,10 +8,11 @@ import (
"strconv"
)
-// BackgroundOpts are the options passed from the serve command.
+// BackgroundOpts are the options passed from the start/serve command.
type BackgroundOpts struct {
ConfigPath string // --config flag value, may be empty
Port int // --port flag value, 0 means default
+ Command string // "start" or "serve", defaults to "serve"
}
// ForkIntoBackground starts the current binary as a detached background process.
@@ -30,8 +31,12 @@ func ForkIntoBackground(opts BackgroundOpts) error {
_ = os.Remove(paths.PIDFile)
}
- // Build args for the child process: routatic-proxy serve --_daemonize [--config X] [--port N]
- args := []string{"serve", "--_daemonize"}
+ // Build args for the child process: routatic-proxy --_daemonize [--config X] [--port N]
+ command := opts.Command
+ if command == "" {
+ command = "serve"
+ }
+ args := []string{command, "--_daemonize"}
if opts.ConfigPath != "" {
configPath, err := filepath.Abs(opts.ConfigPath)
if err != nil {
diff --git a/internal/gui/analytics.go b/internal/gui/analytics.go
new file mode 100644
index 00000000..b5fe3028
--- /dev/null
+++ b/internal/gui/analytics.go
@@ -0,0 +1,102 @@
+package gui
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/routatic/proxy/internal/storage"
+)
+
+// AnalyticsHandler serves analytics endpoints for the dashboard.
+type AnalyticsHandler struct {
+ store *storage.Analytics
+ latency *storage.Latency
+}
+
+// NewAnalyticsHandler creates a handler backed by the given database.
+// It internally creates an Analytics store and a Latency store.
+func NewAnalyticsHandler(db *storage.Database) *AnalyticsHandler {
+ return &AnalyticsHandler{
+ store: storage.NewAnalytics(db),
+ latency: storage.NewLatency(db),
+ }
+}
+
+func (h *AnalyticsHandler) writeJSON(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func (h *AnalyticsHandler) getDays(r *http.Request) int {
+ daysStr := r.URL.Query().Get("days")
+ if daysStr == "" {
+ return 30
+ }
+ d, err := strconv.Atoi(daysStr)
+ if err != nil || d <= 0 {
+ return 30
+ }
+ return d
+}
+
+// Summary returns high-level KPIs and breakdowns.
+func (h *AnalyticsHandler) Summary(w http.ResponseWriter, r *http.Request) {
+ days := h.getDays(r)
+
+ summary, err := h.store.GetTokenSummary(days)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ models, err := h.store.GetModelBreakdown(days)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ providers, err := h.store.GetProviderBreakdown(days)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ resp := map[string]any{
+ "summary": summary,
+ "models": models,
+ "providers": providers,
+ "generated_at": time.Now().Format(time.RFC3339),
+ }
+ h.writeJSON(w, resp)
+}
+
+// TokenTrend returns daily token/request aggregates.
+func (h *AnalyticsHandler) TokenTrend(w http.ResponseWriter, r *http.Request) {
+ days := h.getDays(r)
+ trend, err := h.store.GetDailyTokenTrend(days)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ h.writeJSON(w, map[string]any{
+ "days": days,
+ "trend": trend,
+ })
+}
+
+// LatencyStats returns latency stats per model.
+func (h *AnalyticsHandler) LatencyStats(w http.ResponseWriter, r *http.Request) {
+ days := h.getDays(r)
+ since := time.Now().AddDate(0, 0, -days)
+ stats, err := h.latency.GetStats(since)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ h.writeJSON(w, map[string]any{
+ "days": days,
+ "stats": stats,
+ })
+}
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index 4dc10bc8..c0af53c0 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -8,7 +8,9 @@ const TRANSLATIONS = {
'status.connected': 'Connected',
'tab.overview': 'Overview',
'tab.history': 'History',
+ 'tab.performance': 'Performance',
'tab.fallback': 'Fallback',
+ 'tab.analytics': 'Analytics',
'tab.settings': 'Settings',
'metric.total': 'Total Requests',
'metric.success': 'Success',
@@ -382,7 +384,11 @@ document.querySelectorAll('.tab').forEach(tab => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
- document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
+ const contentId = 'tab-' + tab.dataset.tab;
+ document.getElementById(contentId).classList.add('active');
+ if (tab.dataset.tab === 'analytics') {
+ AnalyticsModule.load(true);
+ }
});
});
@@ -942,7 +948,7 @@ function showHistoryDetail(record) {
Status
- ${record.success ? 'Success' : 'Failed'}
+ ${record.success ? 'Success' : 'Failed'}
`;
modal.classList.add('visible');
@@ -1075,7 +1081,7 @@ document.addEventListener('keydown', function(e) {
// Tab shortcuts: Cmd/Ctrl + 1/2/3/4/5/6
if ((e.metaKey || e.ctrlKey) && ['1', '2', '3', '4', '5', '6'].includes(e.key)) {
e.preventDefault();
- const tabs = ['overview', 'history', 'performance', 'fallback', 'settings'];
+ const tabs = ['overview', 'history', 'performance', 'fallback', 'analytics', 'settings'];
document.querySelector(`[data-tab="${tabs[parseInt(e.key) - 1]}"]`)?.click();
}
// Escape to close modals (use if-else to ensure only one action)
@@ -1169,14 +1175,14 @@ async function handleConfigImport(file) {
${t('modal.importConfirm')}
- ${escapeHtml(JSON.stringify(config, null, 2))}
+ ${escapeHtml(JSON.stringify(config, null, 2))}
`;
modalBody.innerHTML = previewHtml;
document.getElementById('modal-title').textContent = t('modal.importPreview');
const footerHtml = `
-
+
${t('btn.cancel')}
${t('btn.apply')}
@@ -1239,11 +1245,7 @@ document.addEventListener('DOMContentLoaded', () => {
/* ── Fallback Chain Editor ─────────────────────────────────────── */
const FallbackModule = {
- chains: {
- default: [],
- streaming: [],
- 'long-context': []
- },
+ chains: {},
currentScenario: 'default',
originalChains: null,
availableModels: [],
@@ -1258,14 +1260,44 @@ const FallbackModule = {
if (!r.ok) return;
const config = await r.json();
- this.availableModels = config.models || [];
+ // Build model list from scenario models + fallback entries
+ const modelMap = new Map();
+ if (config.models) {
+ for (const [, m] of Object.entries(config.models)) {
+ if (m.model_id && !modelMap.has(m.model_id)) {
+ modelMap.set(m.model_id, { id: m.model_id, display_name: m.model_id, provider: m.provider || 'unknown' });
+ }
+ }
+ }
+ if (config.fallbacks) {
+ for (const models of Object.values(config.fallbacks)) {
+ for (const m of models) {
+ if (m.model_id && !modelMap.has(m.model_id)) {
+ modelMap.set(m.model_id, { id: m.model_id, display_name: m.model_id, provider: m.provider || 'unknown' });
+ }
+ }
+ }
+ }
+ this.availableModels = [...modelMap.values()];
- this.chains = {
- default: this.parseFallbackChain(config, 'default'),
- streaming: this.parseFallbackChain(config, 'streaming'),
- 'long-context': this.parseFallbackChain(config, 'long_context')
- };
+ // Discover all scenario keys from config.models
+ const scenarioKeys = Object.keys(config.models || {});
+ this.chains = {};
+ for (const key of scenarioKeys) {
+ this.chains[key] = this.parseFallbackChain(config, key);
+ }
+ // Populate scenario dropdown
+ const sel = document.getElementById('fallback-scenario');
+ if (sel) {
+ sel.innerHTML = scenarioKeys.map(k =>
+ `
${k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())} `
+ ).join('');
+ this.currentScenario = scenarioKeys[0] || 'default';
+ sel.value = this.currentScenario;
+ }
+
+ this.populateAddSelect();
this.originalChains = JSON.parse(JSON.stringify(this.chains));
this.renderChain();
} catch (e) {
@@ -1274,13 +1306,38 @@ const FallbackModule = {
},
parseFallbackChain(config, scenario) {
- const key = scenario === 'long-context' ? 'long_context' : scenario;
- if (config.router_config && config.router_config.scenario_fallbacks && config.router_config.scenario_fallbacks[key]) {
- return [...config.router_config.scenario_fallbacks[key]];
+ if (config.fallbacks && config.fallbacks[scenario]) {
+ return config.fallbacks[scenario].map(m => ({...m}));
}
return [];
},
+ populateAddSelect() {
+ const addSel = document.getElementById('fallback-add-model');
+ if (!addSel) return;
+ const chain = this.chains[this.currentScenario] || [];
+ const available = this.availableModels
+ .filter(m => !chain.some(e => (e.model_id || e) === m.id));
+ addSel.innerHTML = '
' + t('fallback.selectModel') + ' ' +
+ available.map(m =>
+ `
${escapeHtml(m.display_name || m.id)} (${escapeHtml(m.provider)}) `
+ ).join('');
+ addSel.disabled = available.length === 0;
+ },
+
+ onAddSelectChange() {
+ const addSel = document.getElementById('fallback-add-model');
+ const modelId = addSel.value;
+ if (!modelId) return;
+ const model = this.availableModels.find(m => m.id === modelId);
+ if (model) {
+ (this.chains[this.currentScenario] || []).push({ model_id: modelId, provider: model.provider, temperature: 0, max_tokens: 0 });
+ this.renderChain();
+ }
+ addSel.value = '';
+ this.populateAddSelect();
+ },
+
renderChain() {
const list = document.getElementById('fallback-chain');
const chain = this.chains[this.currentScenario];
@@ -1288,14 +1345,16 @@ const FallbackModule = {
if (!chain || chain.length === 0) {
list.innerHTML = '
' + t('fallback.empty') + ' ';
list.classList.remove('has-items');
+ this.populateAddSelect();
return;
}
list.classList.add('has-items');
- list.innerHTML = chain.map((modelId, index) => {
+ list.innerHTML = chain.map((entry, index) => {
+ const modelId = entry.model_id || entry;
const model = this.availableModels.find(m => m.id === modelId);
const displayName = model ? (model.display_name || model.id) : modelId;
- const provider = model ? model.provider : '';
+ const provider = entry.provider || (model ? model.provider : '');
return `
⋮⋮
@@ -1306,6 +1365,7 @@ const FallbackModule = {
`;
}).join('');
+ this.populateAddSelect();
this.setupDragDrop();
},
@@ -1366,48 +1426,16 @@ const FallbackModule = {
const select = document.getElementById('fallback-scenario');
this.currentScenario = select.value;
this.renderChain();
+ this.populateAddSelect();
document.getElementById('fallback-preview').style.display = 'none';
},
- addModel() {
- const modelOptions = this.availableModels
- .filter(m => !this.chains[this.currentScenario].includes(m.id))
- .map(m => `${escapeHtml(m.display_name || m.id)} (${escapeHtml(m.provider)}) `)
- .join('');
-
- if (!modelOptions) {
- alert(currentLang === 'zh' ? '没有可用模型' : 'No available models');
- return;
- }
-
- const selectHtml = `${modelOptions} `;
- const confirmed = confirm(
- (currentLang === 'zh' ? '选择模型添加到降级链:\n\n' : 'Select a model to add:\n\n') +
- this.availableModels.filter(m => !this.chains[this.currentScenario].includes(m.id))
- .map(m => `${m.display_name || m.id} (${m.provider})`).join('\n')
- );
-
- if (confirmed) {
- const modelId = prompt(
- currentLang === 'zh' ? '输入模型ID:' : 'Enter model ID:',
- this.availableModels.filter(m => !this.chains[this.currentScenario].includes(m.id))[0]?.id || ''
- );
-
- if (modelId && !this.chains[this.currentScenario].includes(modelId)) {
- const model = this.availableModels.find(m => m.id === modelId);
- if (model) {
- this.chains[this.currentScenario].push(modelId);
- this.renderChain();
- } else {
- alert(currentLang === 'zh' ? '无效的模型ID' : 'Invalid model ID');
- }
- }
- }
- },
-
removeModel(index) {
- this.chains[this.currentScenario].splice(index, 1);
- this.renderChain();
+ const chain = this.chains[this.currentScenario];
+ if (chain) {
+ chain.splice(index, 1);
+ this.renderChain();
+ }
},
preview() {
@@ -1419,7 +1447,8 @@ const FallbackModule = {
contentEl.innerHTML = '' + t('fallback.empty') + '
';
} else {
contentEl.innerHTML = '' +
- chain.map((modelId, i) => {
+ chain.map((entry, i) => {
+ const modelId = entry.model_id || entry;
const model = this.availableModels.find(m => m.id === modelId);
const displayName = model ? (model.display_name || model.id) : modelId;
return `
@@ -1450,16 +1479,7 @@ const FallbackModule = {
}
try {
- const patch = {
- router_config: {
- scenario_fallbacks: {
- default: this.chains.default,
- streaming: this.chains.streaming,
- long_context: this.chains['long-context']
- }
- }
- };
-
+ const patch = { fallbacks: { ...this.chains } };
const r = await fetch('/api/proxy/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1707,3 +1727,191 @@ const TestModule = {
document.addEventListener('DOMContentLoaded', () => TestModule.init());
+/* ── Analytics Tab (minimal, vanilla JS + SVG/CSS) ─────────────── */
+const AnalyticsModule = {
+ palette: ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6'],
+
+ init() {
+ const daysSel = document.getElementById('analytics-days');
+ const refreshBtn = document.getElementById('btn-refresh-analytics');
+ if (daysSel) daysSel.addEventListener('change', () => this.load(true));
+ if (refreshBtn) refreshBtn.addEventListener('click', () => this.load(true));
+ },
+
+ loadingHtml() {
+ return '
';
+ },
+
+ async load(force) {
+ const daysEl = document.getElementById('analytics-days');
+ const days = daysEl ? daysEl.value : 30;
+ const genEl = document.getElementById('analytics-generated');
+ if (genEl) genEl.textContent = '';
+ ['kpi-requests','kpi-tokens','kpi-tokens-in','kpi-tokens-out','kpi-cost','kpi-p95'].forEach(id => {
+ const el = document.getElementById(id);
+ if (el) el.textContent = '…';
+ });
+
+ this.showLoading();
+
+ try {
+ const [summaryRes, trendRes, latencyRes] = await Promise.all([
+ fetch(`/api/analytics/summary?days=${days}`),
+ fetch(`/api/analytics/tokens/trend?days=${days}`),
+ fetch(`/api/analytics/latency?days=${days}`)
+ ]);
+ if (!summaryRes.ok) throw new Error('summary fetch failed');
+ const summary = await summaryRes.json();
+ const trend = trendRes.ok ? await trendRes.json() : { trend: [] };
+ const latencyData = latencyRes.ok ? await latencyRes.json() : { stats: [] };
+
+ summary.latency = latencyData;
+
+ this.renderKPIs(summary);
+ this.renderDonuts(summary);
+ this.renderTrend(trend.trend || []);
+ if (genEl) {
+ const ts = summary.generated_at ? new Date(summary.generated_at) : new Date();
+ genEl.textContent = '· ' + ts.toLocaleDateString(undefined, {month:'short', day:'numeric'});
+ }
+ } catch (e) {
+ console.error('Analytics error:', e);
+ this.renderEmpty('Failed to load analytics');
+ if (genEl) genEl.textContent = 'Error';
+ }
+ },
+
+ showLoading() {
+ ['model-donut','provider-donut','token-trend'].forEach(id => {
+ const el = document.getElementById(id);
+ if (el) el.innerHTML = this.loadingHtml();
+ });
+ },
+
+ renderKPIs(data) {
+ const s = data.summary || {};
+ const fmt = (n) => n != null ? Number(n).toLocaleString() : '—';
+ document.getElementById('kpi-requests').textContent = fmt(s.total_requests);
+ const totTok = (s.input_tokens||0) + (s.output_tokens||0);
+ document.getElementById('kpi-tokens').textContent = fmt(totTok);
+ document.getElementById('kpi-tokens-in').textContent = fmt(s.input_tokens);
+ document.getElementById('kpi-tokens-out').textContent = fmt(s.output_tokens);
+ const cost = s.est_cost_usd != null ? '$' + Number(s.est_cost_usd).toFixed(2) : '—';
+ document.getElementById('kpi-cost').textContent = cost;
+
+ const stats = (data.latency && data.latency.stats) || [];
+ let p95Val = '—';
+ if (stats.length) {
+ const avg = stats.reduce((a, st) => a + (st.p95_ms || 0), 0) / stats.length;
+ p95Val = Math.round(avg) + ' ms';
+ }
+ document.getElementById('kpi-p95').textContent = p95Val;
+ },
+
+ renderDonuts(summary) {
+ this.renderDonutChart('model-donut', summary.models || [], 'requests');
+ this.renderDonutChart('provider-donut', summary.providers || [], 'requests');
+ },
+
+ renderDonutChart(containerId, items, valKey) {
+ const wrap = document.getElementById(containerId);
+ if (!wrap) return;
+ wrap.innerHTML = '';
+
+ if (!items.length) {
+ wrap.innerHTML = '
No usage data yet
';
+ return;
+ }
+
+ const sorted = [...items].sort((a,b) => (b[valKey]||0) - (a[valKey]||0));
+ let top = sorted.slice(0,5);
+ const rest = sorted.slice(5);
+ const otherVal = rest.reduce((sum,i) => sum + (i[valKey]||0), 0);
+ if (otherVal > 0) top.push({name: 'Other', [valKey]: otherVal});
+
+ const total = top.reduce((sum,i) => sum + (i[valKey]||0), 0) || 1;
+ let segs = '';
+ let off = 0;
+ const legend = [];
+ top.forEach((it, idx) => {
+ const v = it[valKey] || 0;
+ const pct = v / total * 100;
+ const col = this.palette[idx % this.palette.length];
+ segs += `${col} ${off.toFixed(1)}% ${(off + pct).toFixed(1)}%, `;
+ off += pct;
+ const label = it.model || it.provider || it.name || 'Unknown';
+ legend.push(`
${this.escapeHtml(label)} ${v}
`);
+ });
+
+ const html = `
`;
+ wrap.innerHTML = html;
+ },
+
+ renderTrend(points) {
+ const wrap = document.getElementById('token-trend');
+ if (!wrap) return;
+ wrap.innerHTML = '';
+ if (!points.length) {
+ wrap.innerHTML = '
No trend data yet. Run some requests to see analytics.
';
+ return;
+ }
+
+ const w = 620, h = 188, pad = 30;
+ const maxV = Math.max(1, ...points.map(p => Math.max(p.input_tokens||0, p.output_tokens||0)));
+ const stepX = (w - pad*2) / Math.max(1, points.length - 1);
+
+ const ptsIn = points.map((p,i) => {
+ const x = pad + i*stepX;
+ const y = h - pad - (p.input_tokens||0)/maxV * (h - pad*2);
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ });
+ const ptsOut = points.map((p,i) => {
+ const x = pad + i*stepX;
+ const y = h - pad - (p.output_tokens||0)/maxV * (h - pad*2);
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ });
+
+ const pathIn = 'M' + ptsIn.join(' L');
+ const pathOut = 'M' + ptsOut.join(' L');
+ const areaIn = pathIn + ` L${(pad + (points.length-1)*stepX).toFixed(1)},${h-pad} L${pad},${h-pad} Z`;
+ const areaOut = pathOut + ` L${(pad + (points.length-1)*stepX).toFixed(1)},${h-pad} L${pad},${h-pad} Z`;
+
+ const dots = points.map((p,i) => {
+ const x = (pad + i*stepX).toFixed(1);
+ const yIn = (h - pad - (p.input_tokens||0)/maxV*(h-pad*2)).toFixed(1);
+ const yOut = (h - pad - (p.output_tokens||0)/maxV*(h-pad*2)).toFixed(1);
+ return `
`;
+ }).join('');
+
+ const svg = `
+
+
+
+
+
+ ${dots}
+
+
+ Input tokens
+ Output tokens
+
`;
+ wrap.innerHTML = svg;
+ },
+
+ renderEmpty(msg = 'No usage data yet. Run some requests or configure a model to see analytics.') {
+ ['model-donut','provider-donut','token-trend'].forEach(id => {
+ const el = document.getElementById(id);
+ if (el) el.innerHTML = `
${msg}
`;
+ });
+ },
+
+ escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
+ }
+};
+
+// Boot analytics module (listeners only; data loads when tab is clicked)
+setTimeout(() => {
+ AnalyticsModule.init();
+}, 250);
+
diff --git a/internal/gui/assets/compiled-tailwind.css b/internal/gui/assets/compiled-tailwind.css
new file mode 100644
index 00000000..270b8e5f
--- /dev/null
+++ b/internal/gui/assets/compiled-tailwind.css
@@ -0,0 +1 @@
+*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.visible{visibility:visible}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.bottom-2{bottom:.5rem}.right-2{right:.5rem}.top-0{top:0}.z-10{z-index:10}.my-0\.5{margin-top:.125rem;margin-bottom:.125rem}.mb-0{margin-bottom:0}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.mt-2\.5{margin-top:.625rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-6{height:1.5rem}.h-screen{height:100vh}.min-h-\[220px\]{min-height:220px}.min-h-\[240px\]{min-height:240px}.min-h-\[80px\]{min-height:80px}.w-6{width:1.5rem}.w-full{width:100%}.min-w-\[180px\]{min-width:180px}.max-w-\[800px\]{max-width:800px}.flex-1{flex:1 1 0%}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-text{-webkit-user-select:text;-moz-user-select:text;user-select:text}.resize-y{resize:vertical}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-none{border-style:none}.border-\[\#48484a\]{--tw-border-opacity:1;border-color:rgb(72 72 74/var(--tw-border-opacity,1))}.bg-\[\#1c1c1e\]{--tw-bg-opacity:1;background-color:rgb(28 28 30/var(--tw-bg-opacity,1))}.bg-\[\#2c2c2e\]{--tw-bg-opacity:1;background-color:rgb(44 44 46/var(--tw-bg-opacity,1))}.bg-\[\#3a3a3c\]{--tw-bg-opacity:1;background-color:rgb(58 58 60/var(--tw-bg-opacity,1))}.bg-transparent{background-color:transparent}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-\[\#2c2c2e\]{--tw-gradient-from:#2c2c2e var(--tw-gradient-from-position);--tw-gradient-to:rgba(44,44,46,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-\[\#3a3a3c\]{--tw-gradient-to:#3a3a3c var(--tw-gradient-to-position)}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pb-2{padding-bottom:.5rem}.pt-2{padding-top:.5rem}.text-left{text-align:left}.text-center{text-align:center}.font-\[-apple-system\2c BlinkMacSystemFont\2c \'PingFang_SC\'\2c \'Hiragino_Sans_GB\'\2c \'Segoe_UI\'\2c sans-serif\]{font-family:-apple-system,BlinkMacSystemFont,PingFang SC,Hiragino Sans GB,Segoe UI,sans-serif}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.text-\[26px\]{font-size:26px}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-tight{line-height:1.25}.tracking-wider{letter-spacing:.05em}.text-\[\#30d158\]{--tw-text-opacity:1;color:rgb(48 209 88/var(--tw-text-opacity,1))}.text-\[\#98989d\]{--tw-text-opacity:1;color:rgb(152 152 157/var(--tw-text-opacity,1))}.text-\[\#f5f5f7\]{--tw-text-opacity:1;color:rgb(245 245 247/var(--tw-text-opacity,1))}.text-\[\#ff453a\]{--tw-text-opacity:1;color:rgb(255 69 58/var(--tw-text-opacity,1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-25{opacity:.25}.opacity-40{opacity:.4}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-black\/20{--tw-shadow-color:rgba(0,0,0,.2);--tw-shadow:var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:scale-\[1\.02\]:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-\[1\.02\]:hover{--tw-scale-x:1.02;--tw-scale-y:1.02}.hover\:bg-\[\#3a3a3c\]:hover{--tw-bg-opacity:1;background-color:rgb(58 58 60/var(--tw-bg-opacity,1))}.hover\:text-\[\#f5f5f7\]:hover{--tw-text-opacity:1;color:rgb(245 245 247/var(--tw-text-opacity,1))}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:border-\[\#6366f1\]:focus{--tw-border-opacity:1;border-color:rgb(99 102 241/var(--tw-border-opacity,1))}.focus\:shadow-\[0_0_0_3px_rgba\(99\2c 102\2c 241\2c 0\.25\)\]:focus{--tw-shadow:0 0 0 3px rgba(99,102,241,.25);--tw-shadow-colored:0 0 0 3px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}
\ No newline at end of file
diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html
index d28883f6..8c7b93e5 100644
--- a/internal/gui/assets/index.html
+++ b/internal/gui/assets/index.html
@@ -1,101 +1,102 @@
-
+
routatic-proxy Console
+
-
-
+
-
-