From 747d23a8031c69b9ea983e51deb9be562a625e0d Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 13:00:01 +0200 Subject: [PATCH 01/59] feat: add dual release channel system documentation and automate beta release process --- .github/scripts/get-versions.sh | 64 ++++ .github/workflows/beta-release.yml | 270 ++++++++++++++++ .github/workflows/release.yml | 80 +++-- RELEASE_PROCESS.md | 503 +++++++++++++++++++++++++++++ internal/handlers/messages.go | 17 + 5 files changed, 900 insertions(+), 34 deletions(-) create mode 100755 .github/scripts/get-versions.sh create mode 100644 .github/workflows/beta-release.yml create mode 100644 RELEASE_PROCESS.md diff --git a/.github/scripts/get-versions.sh b/.github/scripts/get-versions.sh new file mode 100755 index 00000000..8c0c73cc --- /dev/null +++ b/.github/scripts/get-versions.sh @@ -0,0 +1,64 @@ +#!/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 +# 3. Outputs both versions as JSON + +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 +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 -n 1) + + # If no tags found, use default + if [ -z "${latest_tag}" ]; then + echo "${DEFAULT_VERSION}" + else + echo "${latest_tag}" + fi +} + +# Generate beta version from production version +generate_beta_version() { + prod_version="$1" + timestamp="$2" + echo "${prod_version}-beta-${timestamp}" +} + +# Output JSON +output_json() { + prod_version="$1" + beta_version="$2" + printf '{\n "prod_version": "%s",\n "beta_version": "%s"\n}\n' "${prod_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..8115161c 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,24 @@ 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 - - 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 +276,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 +295,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 +428,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/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/internal/handlers/messages.go b/internal/handlers/messages.go index a78cf283..98385e75 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -127,6 +127,23 @@ func (w *responseWriter) extractUsageFromSSE(b []byte) { w.usage.cacheCreationInputTokens = val } } + + // OpenAI-compatible usage keys (used by Bedrock Mantle and direct OpenAI streams) + if idx := strings.Index(data, `"prompt_tokens":`); idx != -1 { + if val, err := parseIntAfter(data, idx+len(`"prompt_tokens":`)); err == nil { + // Only set if not already set by Anthropic keys to avoid overwriting + if w.usage.inputTokens == 0 { + w.usage.inputTokens = val + } + } + } + if idx := strings.Index(data, `"completion_tokens":`); idx != -1 { + if val, err := parseIntAfter(data, idx+len(`"completion_tokens":`)); err == nil { + if w.usage.outputTokens == 0 { + w.usage.outputTokens = val + } + } + } } // parseIntAfter parses an integer value starting at the given position in the string. From bb499dab564d49507882d9603e37ff1696f8960b Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 13:13:04 +0200 Subject: [PATCH 02/59] docs: update documentation for dual release channel system and beta/production release processes --- CLAUDE.md | 56 ++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 30 +++++++++++++++++ README.md | 86 ++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 164 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7fed145f..eac542bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,62 @@ 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:** `vX.Y.Z-beta-YYYYMMDD-HHMMSS` (e.g., `v1.2.3-beta-20260712-143022`) +- **GitHub release:** Marked as `prerelease: true` +- **Docker tags:** `vX.Y.Z-beta-YYYYMMDD-HHMMSS` and `beta-X.Y.Z` + +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 +2. Find the latest production version tag matching `v[0-9]*` +3. Generate a beta version by appending `-beta-YYYYMMDD-HHMMSS` +4. Output both versions as JSON for CI consumption + +### 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/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/README.md b/README.md index 673cecf9..83e1b559 100644 --- a/README.md +++ b/README.md @@ -15,25 +15,45 @@ A Go CLI proxy that lets you route [Claude Code](https://docs.anthropic.com/en/d --- -## macOS GUI Version +## GUI Version -This repository provides a native macOS GUI (System Tray + Console Dashboard) for `routatic-proxy`. +This repository provides a cross-platform GUI for `routatic-proxy` with platform-specific implementations: ### 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 +- **System Tray Icon** — Control the proxy server directly from the system tray/menubar (Start, Stop, Autostart, Quit) +- **Interactive Dashboard** — A beautiful dashboard to view real-time request history, model usage metrics, and easily edit/save your configuration without editing JSON files +- **Three Dashboard Tabs:** + - **Overview** — Real-time metrics showing requests received, streamed, succeeded, and failed; model distribution pie chart; current configuration summary + - **History** — Last 1000 requests with timestamps, models used, token counts, durations, and status; filter and search capabilities + - **Settings** — Edit all configuration options through form inputs with validation; hot-reload support (changes apply immediately without restart) +- **App DMG Installer** (macOS) — Package into a standard macOS app with custom icons and launch support -### How to Run +### Platform-Specific Behavior + +**macOS:** +- Native window with system tray integration (requires CGO) +- Cocoa-based GUI framework +- Download the `.dmg` from the **Releases** page + +**Linux:** +- Browser-based GUI opened via `xdg-open` (default, no CGO required) +- For system tray support, build with `CGO_ENABLED=1` after installing: + - Fedora/RHEL: `sudo dnf install libappindicator-gtk3-devel` + - Ubuntu/Debian: `sudo apt install libayatana-appindicator3-dev` -Download the compiled `.dmg` from the **Releases** page of this repository, or run the following command directly: +**Windows:** +- GUI is not supported; use CLI only + +### How to Run ```bash -# Launch with native macOS GUI +# Launch the GUI dashboard routatic-proxy ui ``` +On macOS, this opens a native window. On Linux, it opens your default browser. The GUI connects to the running proxy server automatically. + --- ## Why? @@ -181,6 +201,7 @@ 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 ui Launch the GUI dashboard (native on macOS, browser on Linux) 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 @@ -206,6 +227,55 @@ routatic-proxy --version Show version | [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 | +## Release Channels + +This project uses a dual release channel system: + +### Beta Channel (Automatic) + +- **Trigger:** Every push to `main` branch +- **Version format:** `vX.Y.Z-beta-YYYYMMDD-HHMMSS` +- **Example:** `v1.2.3-beta-20260712-143022` +- **GitHub release:** Marked as prerelease +- **Docker tags:** `vX.Y.Z-beta-YYYYMMDD-HHMMSS` and `beta-X.Y.Z` +- **Use case:** Get the latest features and bug fixes immediately; ideal for testing and early adoption + +Beta releases are automatically created when code is merged to `main`. They include all binaries, checksums, and macOS DMG. + +### Production Channel (Manual) + +- **Trigger:** Manual workflow_dispatch on `releases` branch +- **Version format:** `vX.Y.Z` (semantic versioning) +- **Example:** `v1.2.3` +- **GitHub release:** Marked as stable (not prerelease) +- **Docker tags:** `vX.Y.Z`, `vX.Y`, `vX`, `latest` +- **Use case:** Stable, tested releases for production use + +To create a production release: + +1. Ensure all changes are merged to `main` and tested via beta +2. Create or update the `releases` branch from `main` +3. Go to Actions → Release workflow +4. Click "Run workflow" and specify the version (e.g., `v1.2.3`) +5. 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 + +### Version Detection + +The beta release workflow uses `.github/scripts/get-versions.sh` to: +- Detect the latest production version from the `releases` branch +- Generate a unique beta version with UTC timestamp +- Output both versions in JSON format for the CI workflow + +### Upgrading + +Beta releases can be upgraded to production releases simply by running the production release workflow with the appropriate version number. The beta tag remains in history for reference. + ## Contributing We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, architecture overview, and how to submit pull requests. From 9e17222c61a304ae80f0da5d16a48a73610f175e Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 13:55:55 +0200 Subject: [PATCH 03/59] feat: enhance versioning script and documentation for beta releases; add endpoint handling for AWS Bedrock models --- .github/scripts/get-versions.sh | 47 ++++++++++++++---- CLAUDE.md | 16 +++++-- internal/provider/aws_bedrock.go | 21 ++++++++- internal/provider/aws_bedrock_test.go | 68 +++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 15 deletions(-) diff --git a/.github/scripts/get-versions.sh b/.github/scripts/get-versions.sh index 8c0c73cc..80616f8a 100755 --- a/.github/scripts/get-versions.sh +++ b/.github/scripts/get-versions.sh @@ -3,8 +3,13 @@ # # This script: # 1. Gets the latest production version from releases branch tags -# 2. Generates a beta version with timestamp +# 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 @@ -16,9 +21,9 @@ TAG_PATTERN="v[0-9]*" DEFAULT_VERSION="v0.0.0" # Get current UTC timestamp for beta version -# Format: YYYYMMDD-HHMMSS +# Format: YYYYMMDD.HHMMSS (dot separator for SemVer compatibility) get_timestamp() { - date -u +"%Y%m%d-%H%M%S" + date -u +"%Y%m%d.%H%M%S" } # Get the latest production version from releases branch @@ -28,7 +33,7 @@ get_prod_version() { # 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 -n 1) + latest_tag=$(git tag -l "${TAG_PATTERN}" --sort=-version:refname | head -1) # If no tags found, use default if [ -z "${latest_tag}" ]; then @@ -38,18 +43,44 @@ get_prod_version() { fi } -# Generate beta version from production version +# 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() { - prod_version="$1" + upcoming_version=$(increment_minor_version "$1") timestamp="$2" - echo "${prod_version}-beta-${timestamp}" + echo "${upcoming_version}-beta.${timestamp}" } # Output JSON output_json() { prod_version="$1" + upcoming_version=$(increment_minor_version "$prod_version") beta_version="$2" - printf '{\n "prod_version": "%s",\n "beta_version": "%s"\n}\n' "${prod_version}" "${beta_version}" + + printf '{ + "prod_version": "%s", + "upcoming_version": "%s", + "beta_version": "%s" +} +' "${prod_version}" "${upcoming_version}" "${beta_version}" } # Main execution diff --git a/CLAUDE.md b/CLAUDE.md index eac542bf..fd421769 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,9 +142,9 @@ This project uses a dual release channel system for separating beta and producti ### Beta Channel (Automatic) - **Trigger:** Every push to `main` branch (see `.github/workflows/beta-release.yml`) -- **Version format:** `vX.Y.Z-beta-YYYYMMDD-HHMMSS` (e.g., `v1.2.3-beta-20260712-143022`) +- **Version format:** `v{UPCOMING}.beta.{YYYYMMDD.HHMMSS}` (e.g., `v1.3.0-beta.20260712.143015`) - **GitHub release:** Marked as `prerelease: true` -- **Docker tags:** `vX.Y.Z-beta-YYYYMMDD-HHMMSS` and `beta-X.Y.Z` +- **Docker tags:** `v{UPCOMING}.beta.{YYYYMMDD.HHMMSS}` and `beta-{UPCOMING}` Beta releases are fully automated and include: - Test suite validation @@ -166,11 +166,17 @@ Production releases include all beta features plus: ### Version Detection Script `.github/scripts/get-versions.sh` is used by the beta workflow to: -1. Fetch tags from the `origin/releases` branch -2. Find the latest production version tag matching `v[0-9]*` -3. Generate a beta version by appending `-beta-YYYYMMDD-HHMMSS` +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 diff --git a/internal/provider/aws_bedrock.go b/internal/provider/aws_bedrock.go index bcf0a34a..95c43711 100644 --- a/internal/provider/aws_bedrock.go +++ b/internal/provider/aws_bedrock.go @@ -112,7 +112,7 @@ func (p *AWSBedrockProvider) Stream(ctx context.Context, req *core.NormalizedReq func (p *AWSBedrockProvider) executeOpenAI(ctx context.Context, req *core.NormalizedRequest, model config.ModelConfig) (*core.ExecuteResult, error) { cfg := p.atomic.Get() - endpoint := cfg.AWSBedrock.BaseURL + endpoint := p.bedrockEndpoint(cfg, model.ModelID) apiKey := p.bedrockAPIKey(cfg) openaiReq := transformer.TransformRequestFromNormalized(req, model) @@ -152,7 +152,7 @@ func (p *AWSBedrockProvider) executeOpenAI(ctx context.Context, req *core.Normal func (p *AWSBedrockProvider) streamOpenAI(ctx context.Context, req *core.NormalizedRequest, model config.ModelConfig) (io.ReadCloser, error) { cfg := p.atomic.Get() - endpoint := cfg.AWSBedrock.BaseURL + endpoint := p.bedrockEndpoint(cfg, model.ModelID) apiKey := p.bedrockAPIKey(cfg) openaiReq := transformer.TransformRequestFromNormalized(req, model) @@ -267,6 +267,23 @@ func (p *AWSBedrockProvider) bedrockAPIKey(cfg *config.Config) string { return p.nextAPIKey(cfg.EffectiveAPIKeys()) } +// needsOpenaiPath returns true for models that require the /openai path prefix +// on Bedrock Mantle. Models like xai.grok-* require this path to avoid +// "Berm is not enabled for this account" errors. +func (p *AWSBedrockProvider) needsOpenaiPath(modelID string) bool { + return strings.HasPrefix(modelID, "xai.") +} + +// bedrockEndpoint returns the appropriate endpoint for a given model. +// Some models (e.g., xai.grok) require the /openai path prefix. +func (p *AWSBedrockProvider) bedrockEndpoint(cfg *config.Config, modelID string) string { + baseURL := cfg.AWSBedrock.BaseURL + if p.needsOpenaiPath(modelID) && !strings.Contains(baseURL, "/openai/") { + return strings.Replace(baseURL, "/v1/", "/openai/v1/", 1) + } + return baseURL +} + // doBedrockRequest sends an HTTP request to the Bedrock Mantle endpoint with // the OpenAI-Project header when configured. func (p *AWSBedrockProvider) doBedrockRequest(ctx context.Context, endpoint, apiKey, projectID string, req any, stream bool) (*http.Response, error) { diff --git a/internal/provider/aws_bedrock_test.go b/internal/provider/aws_bedrock_test.go index cb6360c9..9ab2083a 100644 --- a/internal/provider/aws_bedrock_test.go +++ b/internal/provider/aws_bedrock_test.go @@ -273,3 +273,71 @@ func TestIsBedrock(t *testing.T) { } } } + +func TestAWSBedrockProvider_NeedsOpenaiPath(t *testing.T) { + p := NewAWSBedrockProvider(nil) + tests := []struct { + modelID string + want bool + }{ + {"xai.grok-4.3", true}, + {"xai.grok-4.5", true}, + {"xai.grok-2-latest", true}, + {"anthropic.claude-3-5-sonnet", false}, + {"moonshotai.kimi-k2.5", false}, + {"deepseek-v4-pro", false}, + } + for _, tt := range tests { + if got := p.needsOpenaiPath(tt.modelID); got != tt.want { + t.Errorf("needsOpenaiPath(%q) = %v, want %v", tt.modelID, got, tt.want) + } + } +} + +func TestAWSBedrockProvider_BedrockEndpoint(t *testing.T) { + p := NewAWSBedrockProvider(nil) + tests := []struct { + name string + baseURL string + modelID string + wantPath string + }{ + { + name: "xai model without /openai in base", + baseURL: "https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions", + modelID: "xai.grok-4.3", + wantPath: "https://bedrock-mantle.us-east-1.api.aws/openai/v1/chat/completions", + }, + { + name: "xai model already has /openai in base", + baseURL: "https://bedrock-mantle.us-east-1.api.aws/openai/v1/chat/completions", + modelID: "xai.grok-4.3", + wantPath: "https://bedrock-mantle.us-east-1.api.aws/openai/v1/chat/completions", + }, + { + name: "non-xai model keeps original path", + baseURL: "https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions", + modelID: "anthropic.claude-3-5-sonnet", + wantPath: "https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions", + }, + { + name: "kimi model keeps original path", + baseURL: "https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions", + modelID: "moonshotai.kimi-k2.5", + wantPath: "https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{ + AWSBedrock: config.AWSBedrockConfig{ + BaseURL: tt.baseURL, + }, + } + got := p.bedrockEndpoint(cfg, tt.modelID) + if got != tt.wantPath { + t.Errorf("bedrockEndpoint(%q) = %q, want %q", tt.modelID, got, tt.wantPath) + } + }) + } +} From 18ee2be7a99d79af49592fbe8ccf8746c32620e5 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 14:16:55 +0200 Subject: [PATCH 04/59] feat: Enhance OpenRouter integration and improve streaming error handling - Added comprehensive documentation for OpenRouter models, including key benefits, model naming conventions, and popular models. - Updated README to include OpenRouter setup instructions and environment variable configurations. - Implemented content detection in SSE responses to improve error handling during streaming. - Introduced new error type for empty streams and updated streaming logic to handle empty responses gracefully. - Added test cases for AWS Bedrock provider to ensure correct endpoint resolution for various model IDs. --- CONFIGURATION.md | 824 ++++++++++++++++++++++++++ MODELS.md | 284 +++++++++ README.md | 236 ++++++++ internal/handlers/messages.go | 49 +- internal/provider/aws_bedrock_test.go | 6 + internal/transformer/stream.go | 2 + 6 files changed, 1398 insertions(+), 3 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 00400ef0..5254b123 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -211,6 +211,828 @@ 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": { + "base_url": "https://openrouter.ai/api/v1", + "api_key": "${OPENROUTER_API_KEY}", + "api_keys": ["${OPENROUTER_KEY_1}", "${OPENROUTER_KEY_2}"], + "enabled": true + } +} +``` + +| 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` | + +*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 + } + } +} +``` + +## OpenRouter (`openrouter`) + +OpenRouter is an aggregation layer that routes requests to 100+ upstream providers (OpenAI, Anthropic, Google, Together, Fireworks, Groq, etc.) through a single unified API. It provides: + +- **Unified API surface** — one base URL and schema for dozens of providers +- **Automatic fallbacks** — if a model is unavailable on one provider, OpenRouter can route to alternatives +- **Cost visibility** — response metadata includes per-request pricing for transparency +- **Key pooling** — rotate through multiple API keys to increase throughput and avoid rate limits + +Configure OpenRouter in the top-level `openrouter` block: + +```json +{ + "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 + } +} +``` + +### 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 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 (e.g., `key1,key2,key3`) | 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 + } + } +} +``` + + +### 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 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 (e.g., `key1,key2,key3`) | 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 + } + } +} +``` + + +| Field | Type | Description | +|-------|------|-------------| +| `base_url` | `string` | OpenRouter API endpoint. Default: `https://openrouter.ai/api/v1`. Only change if self-hosting a compatible gateway. | +| `api_key` | `string` | Single OpenRouter API key. Supports `${VAR}` interpolation. Use either `api_key` or `api_keys`, not both. | +| `api_keys` | `string[]` | Pool of OpenRouter API keys for round-robin rotation. Each request picks the next key in sequence. Supports `${VAR}` interpolation. | +| `enabled` | `bool` | Master switch. When `false`, OpenRouter is completely disabled even if keys are present. Default: `true`. | +| `timeout_ms` | `int` | Per-request timeout for non-streaming calls. Default: `300000` (5 minutes). | +| `stream_timeout_ms` | `int` | Idle timeout between SSE chunks for streaming responses. If no bytes arrive within this window, the stream is considered stuck and the request fails over. Default: inherits `timeout_ms`. | + +### Single Key vs Key Pool + +Use `api_key` for a single key: + +```json +{ + "openrouter": { + "api_key": "sk-or-v1-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } +} +``` + +Use `api_keys` for a pool that rotates on every request (helps bypass per-key rate limits): + +```json +{ + "openrouter": { + "api_keys": [ + "sk-or-v1-key-one", + "sk-or-v1-key-two", + "sk-or-v1-key-three" + ] + } +} +``` + +Environment variable overrides (single key): + +```bash +export ROUTATIC_PROXY_OPENROUTER_API_KEY=sk-or-v1-xxxx +``` + +Environment variable overrides (comma-separated pool): + +```bash +export ROUTATIC_PROXY_OPENROUTER_API_KEYS=key-1,key-2,key-3 +``` + +Precedence: `*_API_KEYS` → `*_API_KEY` → global `API_KEYS` → global `API_KEY`. + +### Model Routing with OpenRouter + +Set `"provider": "openrouter"` in any model config or fallback entry to route through OpenRouter: + +```json +{ + "models": { + "default": { + "provider": "openrouter", + "model_id": "anthropic/claude-sonnet-4.5", + "temperature": 0.7, + "max_tokens": 8192 + }, + "think": { + "provider": "openrouter", + "model_id": "openai/o3-mini", + "temperature": 0.7, + "max_tokens": 16384, + "reasoning_effort": "high" + } + }, + "fallbacks": { + "default": [ + { "provider": "openrouter", "model_id": "google/gemini-2.0-flash" }, + { "provider": "openrouter", "model_id": "meta-llama/llama-3.3-70b-instruct" } + ] + } +} +``` + +OpenRouter model IDs use the `provider/model` format (e.g., `anthropic/claude-sonnet-4.5`, `openai/gpt-4o`, `google/gemini-2.0-flash`). See the [OpenRouter models catalog](https://openrouter.ai/models) for the full list. + +### Cost-Based Routing Integration + +When `cost_routing.enabled` is true, OpenRouter models participate in cheapest-model selection. Two settings control their behavior: + +**`prefer_providers`** — include or exclude OpenRouter globally: + +```json +{ + "cost_routing": { + "prefer_providers": ["opencode-go", "openrouter"] + } +} +``` + +Only models whose provider is in this list are considered. To exclude OpenRouter entirely, omit it from the array. + +**`penalty_per_provider.openrouter`** — add a cost bias without removing the provider: + +```json +{ + "cost_routing": { + "penalty_per_provider": { + "openrouter": 0.05 + } + } +} +``` + +A model on OpenRouter with raw cost `2.0` and penalty `0.05` has effective cost `2.05`. Use this to prefer direct providers (lower latency, no aggregator markup) while still allowing OpenRouter as a fallback when direct options are expensive or unavailable. + +Example combining both: + +```json +{ + "cost_routing": { + "enabled": true, + "prefer_providers": ["opencode-go", "aws-bedrock", "openrouter"], + "max_context_window": 1000000, + "penalty_per_provider": { + "openrouter": 0.03, + "opencode-go": 0.01 + } + } +} +``` + +### Complete Example Configurations + +**Minimal single-key setup:** + +```json +{ + "openrouter": { + "api_key": "${OPENROUTER_API_KEY}" + }, + "models": { + "default": { + "provider": "openrouter", + "model_id": "anthropic/claude-3.5-sonnet" + } + } +} +``` + +**High-throughput key pool with cost penalties:** + +```json +{ + "openrouter": { + "api_keys": [ + "${OPENROUTER_KEY_A}", + "${OPENROUTER_KEY_B}", + "${OPENROUTER_KEY_C}" + ] + }, + "cost_routing": { + "enabled": true, + "prefer_providers": ["opencode-go", "openrouter"], + "penalty_per_provider": { + "openrouter": 0.04 + } + }, + "models": { + "default": { + "provider": "openrouter", + "model_id": "anthropic/claude-sonnet-4.5" + }, + "think": { + "provider": "openrouter", + "model_id": "openai/o1-preview" + } + } +} +``` + +**Hybrid: direct providers for cheap models, OpenRouter for premium Claude:** + +```json +{ + "opencode_go": { + "base_url": "https://opencode.ai/zen/go/v1/chat/completions" + }, + "openrouter": { + "api_key": "${OPENROUTER_API_KEY}" + }, + "cost_routing": { + "enabled": true, + "prefer_providers": ["opencode-go", "openrouter"], + "penalty_per_provider": { + "openrouter": 0.02 + } + }, + "models": { + "default": { + "provider": "opencode-go", + "model_id": "qwen3.7-plus" + }, + "think": { + "provider": "openrouter", + "model_id": "anthropic/claude-sonnet-4.5", + "reasoning_effort": "high" + } + } +} +``` + ## Environment Variables Environment variables override config file values. Config values also support `${VAR}` interpolation. @@ -223,6 +1045,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/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/README.md b/README.md index 83e1b559..02280694 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,28 @@ 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 + +
+ +[![OpenCode Go](https://img.shields.io/badge/OpenCode_Go-00C853?style=for-the-badge&logo=codeforces&logoColor=white)](https://opencode.ai/docs/go/) +[![OpenCode Zen](https://img.shields.io/badge/OpenCode_Zen-7C4DFF?style=for-the-badge&logo=codeforces&logoColor=white)](https://opencode.ai/docs/zen/) +[![AWS Bedrock](https://img.shields.io/badge/AWS_Bedrock-FF9900?style=for-the-badge&logo=amazon-aws&logoColor=white)](https://aws.amazon.com/bedrock/) +[![OpenRouter](https://img.shields.io/badge/OpenRouter-10A37F?style=for-the-badge&logo=openai&logoColor=white)](https://openrouter.ai/) +[![Anthropic](https://img.shields.io/badge/Anthropic-D4A574?style=for-the-badge&logo=anthropic&logoColor=black)](https://www.anthropic.com/) + +
+ +| 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 | + +**Quick Links:** [Go Models](#supported-models) · [Zen Models](#opencodes-zen) · [OpenRouter Setup](#openrouter-integration) · [Anthropic Mode](#anthropic-first-failover) + `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. @@ -102,6 +124,124 @@ Zen provides pay-as-you-go access to additional models: See [MODELS.md](MODELS.md#opencodes-zen) for the full Zen model list. +### OpenRouter Models + +[OpenRouter](https://openrouter.ai) is a unified API for 100+ 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. + +#### 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, giving you: + +- **Unified API**: One endpoint, one authentication method for 100+ 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 + +#### Getting Started + +1. Sign up at [openrouter.ai](https://openrouter.ai) +2. Generate an API key at [https://openrouter.ai/keys](https://openrouter.ai/keys) +3. 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 +``` + +#### Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `ROUTATIC_PROXY_OPENROUTER_API_KEY` | Single OpenRouter API key | `sk-or-v1-...` | +| `ROUTATIC_PROXY_OPENROUTER_API_KEYS` | Comma-separated list for rotation | `key-1,key-2,key-3` | + +Precedence: `*_API_KEYS` → `*_API_KEY` → global `API_KEYS` → global `API_KEY`. + +#### Configuration Example + +Add the `openrouter` provider to your `config.json`: + +```json +{ + "providers": { + "openrouter": { + "enabled": true, + "api_key": "${ROUTATIC_PROXY_OPENROUTER_API_KEY}", + "base_url": "https://openrouter.ai/api/v1" + } + }, + "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/google/gemini-2.0-flash-exp": { + "enabled": true, + "display_name": "Gemini 2.0 Flash (via OpenRouter)" + }, + "openrouter/meta-llama/llama-3.3-70b-instruct": { + "enabled": true, + "display_name": "Llama 3.3 70B (via OpenRouter)" + }, + "openrouter/mistralai/mistral-large": { + "enabled": true, + "display_name": "Mistral Large (via OpenRouter)" + } + } +} +``` + +#### Cost-Based Routing with 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 + +OpenRouter uses the `provider/model-name` format. Common model slugs: + +| 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 | +| `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 | +| `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) | +| `meta-llama/llama-3.3-70b-instruct` | Meta | Llama 3.3 70B | Open source, self-hostable | + +See the full catalog at [https://openrouter.ai/models](https://openrouter.ai/models). + +#### 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 Docs**: [https://openrouter.ai/docs#provider-routing](https://openrouter.ai/docs#provider-routing) + ### Deprecated Models The following models are deprecated and will be removed: @@ -115,6 +255,102 @@ The following models are deprecated and will be removed: See [MODELS.md](MODELS.md#deprecated-zen-models) for the complete deprecation schedule. +## OpenRouter Integration + +[OpenRouter](https://openrouter.ai) is a unified API for 100+ 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. + +### Getting Started + +1. Sign up at [openrouter.ai](https://openrouter.ai) +2. Generate an API key at [https://openrouter.ai/keys](https://openrouter.ai/keys) +3. Set the environment variable: + +```bash +export ROUTATIC_PROXY_OPENROUTER_API_KEY=sk-or-v1-your-key-here +``` + +For key rotation or load balancing, use comma-separated keys: + +```bash +export ROUTATIC_PROXY_OPENROUTER_API_KEYS=key-1,key-2,key-3 +``` + +### Configuration + +Add the `openrouter` provider to your `config.json`: + +```json +{ + "providers": { + "openrouter": { + "enabled": true, + "api_key": "${ROUTATIC_PROXY_OPENROUTER_API_KEY}", + "base_url": "https://openrouter.ai/api/v1" + } + }, + "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/google/gemini-2.0-flash-exp": { + "enabled": true, + "display_name": "Gemini 2.0 Flash (via OpenRouter)" + } + } +} +``` + +### Cost-Based Routing + +Apply a penalty to OpenRouter requests to account for routing overhead: + +```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 costs are comparable. + +### Model Selection + +OpenRouter uses the `provider/model-name` format. Common model slugs: + +| Model Key | Provider | Notes | +|-----------|----------|-------| +| `openai/gpt-4o` | OpenAI | Latest GPT-4o | +| `openai/o1` | OpenAI | Reasoning model | +| `anthropic/claude-3.5-sonnet` | Anthropic | Claude 3.5 Sonnet | +| `anthropic/claude-3-opus` | Anthropic | Claude 3 Opus | +| `anthropic/claude-3.5-haiku` | Anthropic | Claude 3.5 Haiku | +| `google/gemini-2.0-flash-exp` | Google | Gemini 2.0 Flash | +| `google/gemini-pro-1.5` | Google | Gemini 1.5 Pro | +| `meta-llama/llama-3.1-405b` | Meta | Llama 3.1 405B | +| `meta-llama/llama-3.3-70b-instruct` | Meta | Llama 3.3 70B | +| `mistralai/mistral-large` | Mistral | Mistral Large | +| `mistralai/mistral-medium` | Mistral | Mistral Medium | +| `mistralai/mistral-small` | Mistral | Mistral Small | +| `deepseek/deepseek-chat` | DeepSeek | DeepSeek V3 | +| `perplexity/sonar-reasoning` | Perplexity | Sonar Reasoning | + +See the full catalog at [https://openrouter.ai/models](https://openrouter.ai/models). + +### 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) + ## Quick Start ### 1. Install diff --git a/internal/handlers/messages.go b/internal/handlers/messages.go index 98385e75..3276d8da 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -59,8 +59,8 @@ type responseWriter struct { mu sync.Mutex wroteHeader bool ssePayloadWritten bool - // usage tracks token usage from message_delta events for logging - usage struct { + contentWritten bool + usage struct { inputTokens int outputTokens int cacheReadInputTokens int @@ -86,8 +86,8 @@ func (w *responseWriter) Write(b []byte) (int, error) { } if len(b) > 0 { w.ssePayloadWritten = true - // Extract usage from message_delta events w.extractUsageFromSSE(b) + w.detectContentInSSE(b) } return w.ResponseWriter.Write(b) } @@ -146,6 +146,30 @@ func (w *responseWriter) extractUsageFromSSE(b []byte) { } } +func (w *responseWriter) detectContentInSSE(b []byte) { + data := string(b) + if strings.Contains(data, `"content_block_start"`) || + strings.Contains(data, `"content_block_delta"`) || + strings.Contains(data, `"text_delta"`) || + strings.Contains(data, `"content":"`) || + strings.Contains(data, `"tool_use"`) || + strings.Contains(data, `"thinking_delta"`) { + w.contentWritten = true + } +} + +func (w *responseWriter) hasContent() bool { + w.mu.Lock() + defer w.mu.Unlock() + return w.contentWritten +} + +func (w *responseWriter) getOutputTokens() int { + w.mu.Lock() + defer w.mu.Unlock() + return w.usage.outputTokens +} + // parseIntAfter parses an integer value starting at the given position in the string. func parseIntAfter(s string, start int) (int, error) { if start >= len(s) { @@ -610,6 +634,16 @@ func (h *MessagesHandler) handleStreaming( } return true // continue to next model } + if err == transformer.ErrEmptyStream { + h.logger.Warn("upstream "+action+" stream empty, trying next model", + "model", model.ModelID) + if rw.ssePayloadWritten { + h.sendStreamError(rw, "empty stream after SSE payload started") + h.metrics.RecordFailureForModel(model.ModelID) + return false // abort + } + return true // continue to next model + } h.logger.Warn(action+" streaming failed", "model", model.ModelID, "error", err) if rw.ssePayloadWritten { h.sendStreamError(rw, "all upstream models failed after SSE payload started") @@ -668,6 +702,15 @@ func (h *MessagesHandler) handleStreaming( continue } + if !rw.hasContent() && rw.getOutputTokens() == 0 { + h.logger.Warn("upstream stream returned empty response, trying next model", + "model", model.ModelID, "provider", model.Provider) + if !handleStreamError(transformer.ErrEmptyStream, model, wireFormat.String()) { + return + } + continue + } + recordStreamSuccess(model) return } diff --git a/internal/provider/aws_bedrock_test.go b/internal/provider/aws_bedrock_test.go index 9ab2083a..aa232f52 100644 --- a/internal/provider/aws_bedrock_test.go +++ b/internal/provider/aws_bedrock_test.go @@ -314,6 +314,12 @@ func TestAWSBedrockProvider_BedrockEndpoint(t *testing.T) { modelID: "xai.grok-4.3", wantPath: "https://bedrock-mantle.us-east-1.api.aws/openai/v1/chat/completions", }, + { + name: "zai model uses standard path", + baseURL: "https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions", + modelID: "zai.glm-5", + wantPath: "https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions", + }, { name: "non-xai model keeps original path", baseURL: "https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions", diff --git a/internal/transformer/stream.go b/internal/transformer/stream.go index 7a2ac90f..c97b3e7a 100644 --- a/internal/transformer/stream.go +++ b/internal/transformer/stream.go @@ -24,6 +24,8 @@ var ErrClientDisconnected = fmt.Errorf("client disconnected") // partition). The handler decides whether to fall back to another model. var ErrStreamIdle = fmt.Errorf("upstream stream idle") +var ErrEmptyStream = fmt.Errorf("upstream returned empty stream") + // readBufPool pools read buffers for streaming operations. // sync.Pool reduces GC pressure under concurrent stream load by reusing // 4KB buffers across goroutines instead of allocating fresh ones per read. From 0c0c616f28b395d09e16df7751a3988eb1a6179e Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 14:24:26 +0200 Subject: [PATCH 05/59] feat: Update README and CONFIGURATION.md to enhance OpenRouter integration details and configuration options --- CONFIGURATION.md | 621 +---------------------------------------------- README.md | 125 ++++++++-- 2 files changed, 109 insertions(+), 637 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 5254b123..9cf86198 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -226,7 +226,9 @@ Set `wire_format: "anthropic"` for models that need raw Anthropic Messages forma "base_url": "https://openrouter.ai/api/v1", "api_key": "${OPENROUTER_API_KEY}", "api_keys": ["${OPENROUTER_KEY_1}", "${OPENROUTER_KEY_2}"], - "enabled": true + "enabled": true, + "timeout_ms": 300000, + "stream_timeout_ms": 60000 } } ``` @@ -238,6 +240,8 @@ Set `wire_format: "anthropic"` for models that need raw Anthropic Messages forma | `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. @@ -418,621 +422,6 @@ The `model_id` in your config must match OpenRouter's model identifier exactly ( } ``` -## OpenRouter (`openrouter`) - -OpenRouter is an aggregation layer that routes requests to 100+ upstream providers (OpenAI, Anthropic, Google, Together, Fireworks, Groq, etc.) through a single unified API. It provides: - -- **Unified API surface** — one base URL and schema for dozens of providers -- **Automatic fallbacks** — if a model is unavailable on one provider, OpenRouter can route to alternatives -- **Cost visibility** — response metadata includes per-request pricing for transparency -- **Key pooling** — rotate through multiple API keys to increase throughput and avoid rate limits - -Configure OpenRouter in the top-level `openrouter` block: - -```json -{ - "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 - } -} -``` - -### 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 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 (e.g., `key1,key2,key3`) | 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 - } - } -} -``` - - -### 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 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 (e.g., `key1,key2,key3`) | 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 - } - } -} -``` - - -| Field | Type | Description | -|-------|------|-------------| -| `base_url` | `string` | OpenRouter API endpoint. Default: `https://openrouter.ai/api/v1`. Only change if self-hosting a compatible gateway. | -| `api_key` | `string` | Single OpenRouter API key. Supports `${VAR}` interpolation. Use either `api_key` or `api_keys`, not both. | -| `api_keys` | `string[]` | Pool of OpenRouter API keys for round-robin rotation. Each request picks the next key in sequence. Supports `${VAR}` interpolation. | -| `enabled` | `bool` | Master switch. When `false`, OpenRouter is completely disabled even if keys are present. Default: `true`. | -| `timeout_ms` | `int` | Per-request timeout for non-streaming calls. Default: `300000` (5 minutes). | -| `stream_timeout_ms` | `int` | Idle timeout between SSE chunks for streaming responses. If no bytes arrive within this window, the stream is considered stuck and the request fails over. Default: inherits `timeout_ms`. | - -### Single Key vs Key Pool - -Use `api_key` for a single key: - -```json -{ - "openrouter": { - "api_key": "sk-or-v1-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - } -} -``` - -Use `api_keys` for a pool that rotates on every request (helps bypass per-key rate limits): - -```json -{ - "openrouter": { - "api_keys": [ - "sk-or-v1-key-one", - "sk-or-v1-key-two", - "sk-or-v1-key-three" - ] - } -} -``` - -Environment variable overrides (single key): - -```bash -export ROUTATIC_PROXY_OPENROUTER_API_KEY=sk-or-v1-xxxx -``` - -Environment variable overrides (comma-separated pool): - -```bash -export ROUTATIC_PROXY_OPENROUTER_API_KEYS=key-1,key-2,key-3 -``` - -Precedence: `*_API_KEYS` → `*_API_KEY` → global `API_KEYS` → global `API_KEY`. - -### Model Routing with OpenRouter - -Set `"provider": "openrouter"` in any model config or fallback entry to route through OpenRouter: - -```json -{ - "models": { - "default": { - "provider": "openrouter", - "model_id": "anthropic/claude-sonnet-4.5", - "temperature": 0.7, - "max_tokens": 8192 - }, - "think": { - "provider": "openrouter", - "model_id": "openai/o3-mini", - "temperature": 0.7, - "max_tokens": 16384, - "reasoning_effort": "high" - } - }, - "fallbacks": { - "default": [ - { "provider": "openrouter", "model_id": "google/gemini-2.0-flash" }, - { "provider": "openrouter", "model_id": "meta-llama/llama-3.3-70b-instruct" } - ] - } -} -``` - -OpenRouter model IDs use the `provider/model` format (e.g., `anthropic/claude-sonnet-4.5`, `openai/gpt-4o`, `google/gemini-2.0-flash`). See the [OpenRouter models catalog](https://openrouter.ai/models) for the full list. - -### Cost-Based Routing Integration - -When `cost_routing.enabled` is true, OpenRouter models participate in cheapest-model selection. Two settings control their behavior: - -**`prefer_providers`** — include or exclude OpenRouter globally: - -```json -{ - "cost_routing": { - "prefer_providers": ["opencode-go", "openrouter"] - } -} -``` - -Only models whose provider is in this list are considered. To exclude OpenRouter entirely, omit it from the array. - -**`penalty_per_provider.openrouter`** — add a cost bias without removing the provider: - -```json -{ - "cost_routing": { - "penalty_per_provider": { - "openrouter": 0.05 - } - } -} -``` - -A model on OpenRouter with raw cost `2.0` and penalty `0.05` has effective cost `2.05`. Use this to prefer direct providers (lower latency, no aggregator markup) while still allowing OpenRouter as a fallback when direct options are expensive or unavailable. - -Example combining both: - -```json -{ - "cost_routing": { - "enabled": true, - "prefer_providers": ["opencode-go", "aws-bedrock", "openrouter"], - "max_context_window": 1000000, - "penalty_per_provider": { - "openrouter": 0.03, - "opencode-go": 0.01 - } - } -} -``` - -### Complete Example Configurations - -**Minimal single-key setup:** - -```json -{ - "openrouter": { - "api_key": "${OPENROUTER_API_KEY}" - }, - "models": { - "default": { - "provider": "openrouter", - "model_id": "anthropic/claude-3.5-sonnet" - } - } -} -``` - -**High-throughput key pool with cost penalties:** - -```json -{ - "openrouter": { - "api_keys": [ - "${OPENROUTER_KEY_A}", - "${OPENROUTER_KEY_B}", - "${OPENROUTER_KEY_C}" - ] - }, - "cost_routing": { - "enabled": true, - "prefer_providers": ["opencode-go", "openrouter"], - "penalty_per_provider": { - "openrouter": 0.04 - } - }, - "models": { - "default": { - "provider": "openrouter", - "model_id": "anthropic/claude-sonnet-4.5" - }, - "think": { - "provider": "openrouter", - "model_id": "openai/o1-preview" - } - } -} -``` - -**Hybrid: direct providers for cheap models, OpenRouter for premium Claude:** - -```json -{ - "opencode_go": { - "base_url": "https://opencode.ai/zen/go/v1/chat/completions" - }, - "openrouter": { - "api_key": "${OPENROUTER_API_KEY}" - }, - "cost_routing": { - "enabled": true, - "prefer_providers": ["opencode-go", "openrouter"], - "penalty_per_provider": { - "openrouter": 0.02 - } - }, - "models": { - "default": { - "provider": "opencode-go", - "model_id": "qwen3.7-plus" - }, - "think": { - "provider": "openrouter", - "model_id": "anthropic/claude-sonnet-4.5", - "reasoning_effort": "high" - } - } -} -``` - ## Environment Variables Environment variables override config file values. Config values also support `${VAR}` interpolation. diff --git a/README.md b/README.md index 02280694..81957834 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,28 @@ A Go CLI proxy that lets you route [Claude Code](https://docs.anthropic.com/en/d | **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 | +--- + +## Supported Providers + +
+ +[![OpenCode Go](https://img.shields.io/badge/OpenCode_Go-00C853?style=for-the-badge&logo=codeforces&logoColor=white)](https://opencode.ai/docs/go/) +[![OpenCode Zen](https://img.shields.io/badge/OpenCode_Zen-7C4DFF?style=for-the-badge&logo=codeforces&logoColor=white)](https://opencode.ai/docs/zen/) +[![AWS Bedrock](https://img.shields.io/badge/AWS_Bedrock-FF9900?style=for-the-badge&logo=amazon-aws&logoColor=white)](https://aws.amazon.com/bedrock/) +[![OpenRouter](https://img.shields.io/badge/OpenRouter-10A37F?style=for-the-badge&logo=openai&logoColor=white)](https://openrouter.ai/) +[![Anthropic](https://img.shields.io/badge/Anthropic-D4A574?style=for-the-badge&logo=anthropic&logoColor=black)](https://www.anthropic.com/) + +
+ +| 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 | + **Quick Links:** [Go Models](#supported-models) · [Zen Models](#opencodes-zen) · [OpenRouter Setup](#openrouter-integration) · [Anthropic Mode](#anthropic-first-failover) `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. @@ -259,6 +281,16 @@ See [MODELS.md](MODELS.md#deprecated-zen-models) for the complete deprecation sc [OpenRouter](https://openrouter.ai) is a unified API for 100+ 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. +### 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, giving you: + +- **Unified API**: One endpoint, one authentication method for 100+ 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 + ### Getting Started 1. Sign up at [openrouter.ai](https://openrouter.ai) @@ -269,13 +301,22 @@ See [MODELS.md](MODELS.md#deprecated-zen-models) for the complete deprecation sc export ROUTATIC_PROXY_OPENROUTER_API_KEY=sk-or-v1-your-key-here ``` -For key rotation or load balancing, use comma-separated keys: +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 ``` -### Configuration +### Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `ROUTATIC_PROXY_OPENROUTER_API_KEY` | Single OpenRouter API key | `sk-or-v1-...` | +| `ROUTATIC_PROXY_OPENROUTER_API_KEYS` | Comma-separated list for rotation | `key-1,key-2,key-3` | + +Precedence: `*_API_KEYS` → `*_API_KEY` → global `API_KEYS` → global `API_KEY`. + +### Complete Configuration Example Add the `openrouter` provider to your `config.json`: @@ -297,17 +338,57 @@ Add the `openrouter` provider to your `config.json`: "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/mistralai/mistral-medium": { + "enabled": true, + "display_name": "Mistral Medium (via OpenRouter)" + }, + "openrouter/mistralai/mistral-small": { + "enabled": true, + "display_name": "Mistral Small (via OpenRouter)" + }, + "openrouter/deepseek/deepseek-chat": { + "enabled": true, + "display_name": "DeepSeek V3 (via OpenRouter)" + }, + "openrouter/perplexity/sonar-reasoning": { + "enabled": true, + "display_name": "Perplexity Sonar Reasoning (via OpenRouter)" } } } ``` -### Cost-Based Routing +### Cost-Based Routing with OpenRouter -Apply a penalty to OpenRouter requests to account for routing overhead: +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 { @@ -321,28 +402,28 @@ Apply a penalty to OpenRouter requests to account for routing overhead: } ``` -This adds a small cost penalty (e.g., 5 cents per million tokens) when selecting OpenRouter models, helping the router prefer direct providers when costs are comparable. +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 OpenRouter uses the `provider/model-name` format. Common model slugs: -| Model Key | Provider | Notes | -|-----------|----------|-------| -| `openai/gpt-4o` | OpenAI | Latest GPT-4o | -| `openai/o1` | OpenAI | Reasoning model | -| `anthropic/claude-3.5-sonnet` | Anthropic | Claude 3.5 Sonnet | -| `anthropic/claude-3-opus` | Anthropic | Claude 3 Opus | -| `anthropic/claude-3.5-haiku` | Anthropic | Claude 3.5 Haiku | -| `google/gemini-2.0-flash-exp` | Google | Gemini 2.0 Flash | -| `google/gemini-pro-1.5` | Google | Gemini 1.5 Pro | -| `meta-llama/llama-3.1-405b` | Meta | Llama 3.1 405B | -| `meta-llama/llama-3.3-70b-instruct` | Meta | Llama 3.3 70B | -| `mistralai/mistral-large` | Mistral | Mistral Large | -| `mistralai/mistral-medium` | Mistral | Mistral Medium | -| `mistralai/mistral-small` | Mistral | Mistral Small | -| `deepseek/deepseek-chat` | DeepSeek | DeepSeek V3 | -| `perplexity/sonar-reasoning` | Perplexity | Sonar Reasoning | +| 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 | +| `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 | +| `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) | +| `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 | +| `perplexity/sonar-reasoning` | Perplexity | Sonar Reasoning | Research, citations | See the full catalog at [https://openrouter.ai/models](https://openrouter.ai/models). @@ -350,6 +431,8 @@ See the full catalog at [https://openrouter.ai/models](https://openrouter.ai/mod - **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) ## Quick Start From 7b82b552938c1266cdbb5bb0505e31fbd1818670 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 14:39:13 +0200 Subject: [PATCH 06/59] Add OpenRouter support and enhance AWS Bedrock integration - Introduced OpenRouter models documentation detailing supported models, context windows, and pricing. - Created a new OpenRouter provider configuration guide for seamless integration. - Enhanced AWS Bedrock provider to support OpenAI Responses API for models prefixed with "openai.gpt-". - Implemented request handling for OpenAI Responses in AWS Bedrock, including streaming capabilities. - Updated tests to validate new OpenAI path handling and ensure correct endpoint usage for OpenAI models. --- CONFIGURATION.md | 1 + README.md | 536 +++----------------------- docs/models.md | 68 ++++ docs/openrouter.md | 396 +++++++++++++++++++ internal/provider/aws_bedrock.go | 98 ++++- internal/provider/aws_bedrock_test.go | 10 + 6 files changed, 615 insertions(+), 494 deletions(-) create mode 100644 docs/models.md create mode 100644 docs/openrouter.md diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 9cf86198..1fc1bef1 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -223,6 +223,7 @@ Set `wire_format: "anthropic"` for models that need raw Anthropic Messages forma ```json { "openrouter": { + "name": "openrouter", "base_url": "https://openrouter.ai/api/v1", "api_key": "${OPENROUTER_API_KEY}", "api_keys": ["${OPENROUTER_KEY_1}", "${OPENROUTER_KEY_2}"], diff --git a/README.md b/README.md index 81957834..9e821516 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,6 @@ **[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
@@ -31,27 +29,7 @@ A Go CLI proxy that lets you route [Claude Code](https://docs.anthropic.com/en/d --- -## Supported Providers - -
- -[![OpenCode Go](https://img.shields.io/badge/OpenCode_Go-00C853?style=for-the-badge&logo=codeforces&logoColor=white)](https://opencode.ai/docs/go/) -[![OpenCode Zen](https://img.shields.io/badge/OpenCode_Zen-7C4DFF?style=for-the-badge&logo=codeforces&logoColor=white)](https://opencode.ai/docs/zen/) -[![AWS Bedrock](https://img.shields.io/badge/AWS_Bedrock-FF9900?style=for-the-badge&logo=amazon-aws&logoColor=white)](https://aws.amazon.com/bedrock/) -[![OpenRouter](https://img.shields.io/badge/OpenRouter-10A37F?style=for-the-badge&logo=openai&logoColor=white)](https://openrouter.ai/) -[![Anthropic](https://img.shields.io/badge/Anthropic-D4A574?style=for-the-badge&logo=anthropic&logoColor=black)](https://www.anthropic.com/) - -
- -| 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 | - -**Quick Links:** [Go Models](#supported-models) · [Zen Models](#opencodes-zen) · [OpenRouter Setup](#openrouter-integration) · [Anthropic Mode](#anthropic-first-failover) +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. `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. @@ -59,542 +37,118 @@ A Go CLI proxy that lets you route [Claude Code](https://docs.anthropic.com/en/d --- -## GUI Version - -This repository provides a cross-platform GUI for `routatic-proxy` with platform-specific implementations: - -### Features - -- **System Tray Icon** — Control the proxy server directly from the system tray/menubar (Start, Stop, Autostart, Quit) -- **Interactive Dashboard** — A beautiful dashboard to view real-time request history, model usage metrics, and easily edit/save your configuration without editing JSON files -- **Three Dashboard Tabs:** - - **Overview** — Real-time metrics showing requests received, streamed, succeeded, and failed; model distribution pie chart; current configuration summary - - **History** — Last 1000 requests with timestamps, models used, token counts, durations, and status; filter and search capabilities - - **Settings** — Edit all configuration options through form inputs with validation; hot-reload support (changes apply immediately without restart) -- **App DMG Installer** (macOS) — Package into a standard macOS app with custom icons and launch support - -### Platform-Specific Behavior - -**macOS:** -- Native window with system tray integration (requires CGO) -- Cocoa-based GUI framework -- Download the `.dmg` from the **Releases** page - -**Linux:** -- Browser-based GUI opened via `xdg-open` (default, no CGO required) -- For system tray support, build with `CGO_ENABLED=1` after installing: - - Fedora/RHEL: `sudo dnf install libappindicator-gtk3-devel` - - Ubuntu/Debian: `sudo apt install libayatana-appindicator3-dev` - -**Windows:** -- GUI is not supported; use CLI only - -### How to Run - -```bash -# Launch the GUI dashboard -routatic-proxy ui -``` - -On macOS, this opens a native window. On Linux, it opens your default browser. The GUI connects to the running proxy server automatically. - ---- - ## 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 [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](https://openrouter.ai) is a unified API for 100+ 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. - -#### 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, giving you: - -- **Unified API**: One endpoint, one authentication method for 100+ 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 - -#### Getting Started - -1. Sign up at [openrouter.ai](https://openrouter.ai) -2. Generate an API key at [https://openrouter.ai/keys](https://openrouter.ai/keys) -3. 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 -``` - -#### Environment Variables - -| Variable | Description | Example | -|----------|-------------|---------| -| `ROUTATIC_PROXY_OPENROUTER_API_KEY` | Single OpenRouter API key | `sk-or-v1-...` | -| `ROUTATIC_PROXY_OPENROUTER_API_KEYS` | Comma-separated list for rotation | `key-1,key-2,key-3` | - -Precedence: `*_API_KEYS` → `*_API_KEY` → global `API_KEYS` → global `API_KEY`. - -#### Configuration Example - -Add the `openrouter` provider to your `config.json`: - -```json -{ - "providers": { - "openrouter": { - "enabled": true, - "api_key": "${ROUTATIC_PROXY_OPENROUTER_API_KEY}", - "base_url": "https://openrouter.ai/api/v1" - } - }, - "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/google/gemini-2.0-flash-exp": { - "enabled": true, - "display_name": "Gemini 2.0 Flash (via OpenRouter)" - }, - "openrouter/meta-llama/llama-3.3-70b-instruct": { - "enabled": true, - "display_name": "Llama 3.3 70B (via OpenRouter)" - }, - "openrouter/mistralai/mistral-large": { - "enabled": true, - "display_name": "Mistral Large (via OpenRouter)" - } - } -} -``` - -#### Cost-Based Routing with 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 - -OpenRouter uses the `provider/model-name` format. Common model slugs: - -| 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 | -| `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 | -| `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) | -| `meta-llama/llama-3.3-70b-instruct` | Meta | Llama 3.3 70B | Open source, self-hostable | - -See the full catalog at [https://openrouter.ai/models](https://openrouter.ai/models). +See [docs/architecture.md](docs/architecture.md) for system design and request flow details. -#### 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 Docs**: [https://openrouter.ai/docs#provider-routing](https://openrouter.ai/docs#provider-routing) - -### 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) - -See [MODELS.md](MODELS.md#deprecated-zen-models) for the complete deprecation schedule. - -## OpenRouter Integration - -[OpenRouter](https://openrouter.ai) is a unified API for 100+ 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. - -### 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, giving you: - -- **Unified API**: One endpoint, one authentication method for 100+ 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 - -### Getting Started +## GUI Version -1. Sign up at [openrouter.ai](https://openrouter.ai) -2. Generate an API key at [https://openrouter.ai/keys](https://openrouter.ai/keys) -3. Set the environment variable: +This repository provides a cross-platform GUI for `routatic-proxy`: -```bash -export ROUTATIC_PROXY_OPENROUTER_API_KEY=sk-or-v1-your-key-here -``` +- **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). -For key rotation or load balancing across multiple keys, use a comma-separated list: +**Dashboard tabs:** Overview (real-time metrics & model distribution), History (last 1000 requests with filters), Settings (edit config with hot-reload). ```bash -export ROUTATIC_PROXY_OPENROUTER_API_KEYS=key-1,key-2,key-3 -``` - -### Environment Variables - -| Variable | Description | Example | -|----------|-------------|---------| -| `ROUTATIC_PROXY_OPENROUTER_API_KEY` | Single OpenRouter API key | `sk-or-v1-...` | -| `ROUTATIC_PROXY_OPENROUTER_API_KEYS` | Comma-separated list for rotation | `key-1,key-2,key-3` | - -Precedence: `*_API_KEYS` → `*_API_KEY` → global `API_KEYS` → global `API_KEY`. - -### Complete Configuration Example - -Add the `openrouter` provider to your `config.json`: - -```json -{ - "providers": { - "openrouter": { - "enabled": true, - "api_key": "${ROUTATIC_PROXY_OPENROUTER_API_KEY}", - "base_url": "https://openrouter.ai/api/v1" - } - }, - "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/mistralai/mistral-medium": { - "enabled": true, - "display_name": "Mistral Medium (via OpenRouter)" - }, - "openrouter/mistralai/mistral-small": { - "enabled": true, - "display_name": "Mistral Small (via OpenRouter)" - }, - "openrouter/deepseek/deepseek-chat": { - "enabled": true, - "display_name": "DeepSeek V3 (via OpenRouter)" - }, - "openrouter/perplexity/sonar-reasoning": { - "enabled": true, - "display_name": "Perplexity Sonar Reasoning (via OpenRouter)" - } - } -} -``` - -### Cost-Based Routing with 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 - } - } -} +routatic-proxy ui ``` -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 - -OpenRouter uses the `provider/model-name` format. Common model slugs: - -| 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 | -| `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 | -| `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) | -| `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 | -| `perplexity/sonar-reasoning` | Perplexity | Sonar Reasoning | Research, citations | - -See the full catalog at [https://openrouter.ai/models](https://openrouter.ai/models). - -### 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) +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 ui Launch the GUI dashboard (native on macOS, browser on Linux) +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: +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:** `vX.Y.Z-beta-YYYYMMDD-HHMMSS` -- **Example:** `v1.2.3-beta-20260712-143022` +- **Version format:** `v{UPCOMING}.beta.{YYYYMMDD.HHMMSS}` (e.g., `v1.3.0-beta.20260712.143015`) - **GitHub release:** Marked as prerelease -- **Docker tags:** `vX.Y.Z-beta-YYYYMMDD-HHMMSS` and `beta-X.Y.Z` -- **Use case:** Get the latest features and bug fixes immediately; ideal for testing and early adoption - -Beta releases are automatically created when code is merged to `main`. They include all binaries, checksums, and macOS DMG. +- **Use case:** Get the latest features and bug fixes immediately; ideal for testing ### Production Channel (Manual) - -- **Trigger:** Manual workflow_dispatch on `releases` branch +- **Trigger:** Manual `workflow_dispatch` on `releases` branch - **Version format:** `vX.Y.Z` (semantic versioning) -- **Example:** `v1.2.3` -- **GitHub release:** Marked as stable (not prerelease) +- **GitHub release:** Marked as stable - **Docker tags:** `vX.Y.Z`, `vX.Y`, `vX`, `latest` - **Use case:** Stable, tested releases for production use -To create a production release: - -1. Ensure all changes are merged to `main` and tested via beta -2. Create or update the `releases` branch from `main` -3. Go to Actions → Release workflow -4. Click "Run workflow" and specify the version (e.g., `v1.2.3`) -5. 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 - -### Version Detection - -The beta release workflow uses `.github/scripts/get-versions.sh` to: -- Detect the latest production version from the `releases` branch -- Generate a unique beta version with UTC timestamp -- Output both versions in JSON format for the CI workflow - -### Upgrading - -Beta releases can be upgraded to production releases simply by running the production release workflow with the appropriate version number. The beta tag remains in history for reference. - ## Contributing We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, architecture overview, and how to submit pull requests. 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/internal/provider/aws_bedrock.go b/internal/provider/aws_bedrock.go index 95c43711..79fb9c27 100644 --- a/internal/provider/aws_bedrock.go +++ b/internal/provider/aws_bedrock.go @@ -58,6 +58,8 @@ func (p *AWSBedrockProvider) WireFormat(modelID string) core.WireFormat { switch { case strings.HasPrefix(modelID, "anthropic."): return core.WireFormatAnthropic + case strings.HasPrefix(modelID, "openai.gpt-"): + return core.WireFormatOpenAIResponses case strings.HasPrefix(modelID, "openai."): return core.WireFormatOpenAIChat default: @@ -88,21 +90,23 @@ func (p *AWSBedrockProvider) StreamIdleTimeout(model config.ModelConfig) time.Du return time.Duration(ms) * time.Millisecond } -// Execute sends a non-streaming request and returns the response. func (p *AWSBedrockProvider) Execute(ctx context.Context, req *core.NormalizedRequest, model config.ModelConfig) (*core.ExecuteResult, error) { switch p.WireFormat(model.ModelID) { case core.WireFormatAnthropic: return p.executeAnthropic(ctx, req, model) + case core.WireFormatOpenAIResponses: + return p.executeResponses(ctx, req, model) default: return p.executeOpenAI(ctx, req, model) } } -// Stream sends a streaming request and returns an io.ReadCloser for SSE events. func (p *AWSBedrockProvider) Stream(ctx context.Context, req *core.NormalizedRequest, model config.ModelConfig) (io.ReadCloser, error) { switch p.WireFormat(model.ModelID) { case core.WireFormatAnthropic: return p.streamAnthropic(ctx, req, model) + case core.WireFormatOpenAIResponses: + return p.streamResponses(ctx, req, model) default: return p.streamOpenAI(ctx, req, model) } @@ -167,6 +171,79 @@ func (p *AWSBedrockProvider) streamOpenAI(ctx context.Context, req *core.Normali return resp.Body, nil } +// ── OpenAI Responses API ────────────────────────────────────────────── + +func (p *AWSBedrockProvider) executeResponses(ctx context.Context, req *core.NormalizedRequest, model config.ModelConfig) (*core.ExecuteResult, error) { + cfg := p.atomic.Get() + endpoint := p.responsesEndpoint(cfg) + apiKey := p.bedrockAPIKey(cfg) + + responsesReq := p.buildResponsesRequest(req, model) + + start := time.Now() + resp, err := p.doBedrockRequest(ctx, endpoint, apiKey, cfg.AWSBedrock.ProjectID, responsesReq, false) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var responsesResp types.ResponsesResponse + if err := json.Unmarshal(body, &responsesResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + normResp := transformer.ResponsesToNormalized(&responsesResp, model.ModelID) + anthropicResp := core.DenormalizeResponse(normResp) + resultBody, err := json.Marshal(anthropicResp) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return &core.ExecuteResult{ + Body: resultBody, + ModelID: model.ModelID, + Latency: time.Since(start), + }, nil +} + +func (p *AWSBedrockProvider) streamResponses(ctx context.Context, req *core.NormalizedRequest, model config.ModelConfig) (io.ReadCloser, error) { + cfg := p.atomic.Get() + endpoint := p.responsesEndpoint(cfg) + apiKey := p.bedrockAPIKey(cfg) + + responsesReq := p.buildResponsesRequest(req, model) + responsesReq.Stream = true + + resp, err := p.doBedrockRequest(ctx, endpoint, apiKey, cfg.AWSBedrock.ProjectID, responsesReq, true) + if err != nil { + return nil, err + } + + return resp.Body, nil +} + +func (p *AWSBedrockProvider) buildResponsesRequest(req *core.NormalizedRequest, model config.ModelConfig) *types.ResponsesRequest { + var inputs []types.ResponsesInput + for _, msg := range req.Messages { + contentBytes, _ := json.Marshal(msg.Content) + inputs = append(inputs, types.ResponsesInput{ + Role: msg.Role, + Content: contentBytes, + }) + } + + return &types.ResponsesRequest{ + Model: model.ModelID, + Input: inputs, + Stream: false, + } +} + // ── Anthropic Messages ──────────────────────────────────────────────── func (p *AWSBedrockProvider) executeAnthropic(ctx context.Context, req *core.NormalizedRequest, model config.ModelConfig) (*core.ExecuteResult, error) { @@ -271,7 +348,7 @@ func (p *AWSBedrockProvider) bedrockAPIKey(cfg *config.Config) string { // on Bedrock Mantle. Models like xai.grok-* require this path to avoid // "Berm is not enabled for this account" errors. func (p *AWSBedrockProvider) needsOpenaiPath(modelID string) bool { - return strings.HasPrefix(modelID, "xai.") + return strings.HasPrefix(modelID, "xai.") || strings.HasPrefix(modelID, "openai.gpt-") } // bedrockEndpoint returns the appropriate endpoint for a given model. @@ -284,6 +361,21 @@ func (p *AWSBedrockProvider) bedrockEndpoint(cfg *config.Config, modelID string) return baseURL } +// responsesEndpoint returns the Responses API endpoint for models like openai.gpt-*. +func (p *AWSBedrockProvider) responsesEndpoint(cfg *config.Config) string { + baseURL := cfg.AWSBedrock.BaseURL + if strings.Contains(baseURL, "/chat/completions") { + return strings.Replace(baseURL, "/chat/completions", "/responses", 1) + } + if strings.HasSuffix(baseURL, "/v1/") { + return strings.TrimSuffix(baseURL, "/") + "/responses" + } + if strings.HasSuffix(baseURL, "/v1") { + return baseURL + "/responses" + } + return baseURL + "/responses" +} + // doBedrockRequest sends an HTTP request to the Bedrock Mantle endpoint with // the OpenAI-Project header when configured. func (p *AWSBedrockProvider) doBedrockRequest(ctx context.Context, endpoint, apiKey, projectID string, req any, stream bool) (*http.Response, error) { diff --git a/internal/provider/aws_bedrock_test.go b/internal/provider/aws_bedrock_test.go index aa232f52..2e3bd7ec 100644 --- a/internal/provider/aws_bedrock_test.go +++ b/internal/provider/aws_bedrock_test.go @@ -283,9 +283,13 @@ func TestAWSBedrockProvider_NeedsOpenaiPath(t *testing.T) { {"xai.grok-4.3", true}, {"xai.grok-4.5", true}, {"xai.grok-2-latest", true}, + {"openai.gpt-5.5", true}, + {"openai.gpt-5.4", true}, + {"openai.gpt-4.1", true}, {"anthropic.claude-3-5-sonnet", false}, {"moonshotai.kimi-k2.5", false}, {"deepseek-v4-pro", false}, + {"zai.glm-5", false}, } for _, tt := range tests { if got := p.needsOpenaiPath(tt.modelID); got != tt.want { @@ -332,6 +336,12 @@ func TestAWSBedrockProvider_BedrockEndpoint(t *testing.T) { modelID: "moonshotai.kimi-k2.5", wantPath: "https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions", }, + { + name: "openai.gpt model uses openai path", + baseURL: "https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions", + modelID: "openai.gpt-5.5", + wantPath: "https://bedrock-mantle.us-east-1.api.aws/openai/v1/chat/completions", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 2bb025bdb42ac91d4cb6f84d35c6f9b8370c48b9 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 14:52:48 +0200 Subject: [PATCH 07/59] feat: Implement analytics handler and storage for dashboard metrics --- internal/gui/analytics.go | 96 ++++++++++++++ internal/storage/analytics.go | 231 ++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 internal/gui/analytics.go create mode 100644 internal/storage/analytics.go diff --git a/internal/gui/analytics.go b/internal/gui/analytics.go new file mode 100644 index 00000000..a2040d6d --- /dev/null +++ b/internal/gui/analytics.go @@ -0,0 +1,96 @@ +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 +} + +// NewAnalyticsHandler creates a handler backed by the given analytics store. +func NewAnalyticsHandler(store *storage.Analytics) *AnalyticsHandler { + return &AnalyticsHandler{store: store} +} + +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 (avg for now). +func (h *AnalyticsHandler) LatencyStats(w http.ResponseWriter, r *http.Request) { + days := h.getDays(r) + stats, err := h.store.GetModelBreakdown(days) + 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/storage/analytics.go b/internal/storage/analytics.go new file mode 100644 index 00000000..b16a4221 --- /dev/null +++ b/internal/storage/analytics.go @@ -0,0 +1,231 @@ +package storage + +import ( + "context" + "time" +) + +// Analytics provides aggregated metrics for the dashboard. +type Analytics struct { + db *Database +} + +// NewAnalytics creates a new Analytics store. +func NewAnalytics(db *Database) *Analytics { + return &Analytics{db: db} +} + +// TokenSummary holds high-level token and request metrics for a time window. +type TokenSummary struct { + TotalRequests int64 `json:"total_requests"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + SuccessRate float64 `json:"success_rate"` // 0-1 + PeriodStart time.Time `json:"period_start"` + PeriodEnd time.Time `json:"period_end"` +} + +// GetTokenSummary returns aggregated token/request metrics for the last N days. +func (a *Analytics) GetTokenSummary(days int) (*TokenSummary, error) { + if days <= 0 { + days = 30 + } + since := time.Now().AddDate(0, 0, -days) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var summary TokenSummary + summary.PeriodStart = since + summary.PeriodEnd = time.Now() + + row := a.db.DB().QueryRowContext(ctx, ` + SELECT + COUNT(*) AS total_requests, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + CASE + WHEN COUNT(*) > 0 THEN CAST(SUM(success) AS FLOAT) / COUNT(*) + ELSE 0 + END AS success_rate + FROM requests + WHERE created_at >= ? + `, since.Format(time.RFC3339Nano)) + + if err := row.Scan(&summary.TotalRequests, &summary.InputTokens, &summary.OutputTokens, &summary.SuccessRate); err != nil { + return nil, err + } + return &summary, nil +} + +// ModelBreakdown holds per-model usage and performance stats. +type ModelBreakdown struct { + Model string `json:"model"` + Provider string `json:"provider"` + Requests int64 `json:"requests"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + AvgLatencyMs float64 `json:"avg_latency_ms"` + SuccessRate float64 `json:"success_rate"` + EstCostUSD float64 `json:"est_cost_usd"` // based on models.cost_* if available +} + +// GetModelBreakdown returns usage stats per model for the last N days. +func (a *Analytics) GetModelBreakdown(days int) ([]ModelBreakdown, error) { + if days <= 0 { + days = 30 + } + since := time.Now().AddDate(0, 0, -days) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + rows, err := a.db.DB().QueryContext(ctx, ` + SELECT + r.model, + COALESCE(r.provider, '') AS provider, + COUNT(*) AS requests, + COALESCE(SUM(r.input_tokens), 0) AS input_tokens, + COALESCE(SUM(r.output_tokens), 0) AS output_tokens, + COALESCE(AVG(l.latency_ms), 0) AS avg_latency_ms, + CASE + WHEN COUNT(*) > 0 THEN CAST(SUM(r.success) AS FLOAT) / COUNT(*) + ELSE 0 + END AS success_rate, + COALESCE( + (SUM(r.input_tokens) * COALESCE(m.cost_input_per_m, 0) + + SUM(r.output_tokens) * COALESCE(m.cost_output_per_m, 0)) / 1000000, + 0 + ) AS est_cost_usd + FROM requests r + LEFT JOIN latency_samples l + ON l.model = r.model + AND l.recorded_at >= ? + LEFT JOIN models m + ON m.id = r.model + WHERE r.created_at >= ? + GROUP BY r.model, r.provider + ORDER BY requests DESC + `, since.Format(time.RFC3339Nano), since.Format(time.RFC3339Nano)) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []ModelBreakdown + for rows.Next() { + var mb ModelBreakdown + if err := rows.Scan( + &mb.Model, + &mb.Provider, + &mb.Requests, + &mb.InputTokens, + &mb.OutputTokens, + &mb.AvgLatencyMs, + &mb.SuccessRate, + &mb.EstCostUSD, + ); err != nil { + return nil, err + } + result = append(result, mb) + } + return result, rows.Err() +} + +// ProviderBreakdown holds per-provider aggregates. +type ProviderBreakdown struct { + Provider string `json:"provider"` + Requests int64 `json:"requests"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + FallbackRate float64 `json:"fallback_rate"` // % of requests that were fallbacks + EstCostUSD float64 `json:"est_cost_usd"` +} + +// GetProviderBreakdown returns usage by provider (with fallback rate). +func (a *Analytics) GetProviderBreakdown(days int) ([]ProviderBreakdown, error) { + if days <= 0 { + days = 30 + } + since := time.Now().AddDate(0, 0, -days) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + rows, err := a.db.DB().QueryContext(ctx, ` + SELECT + COALESCE(provider, 'unknown') AS provider, + COUNT(*) AS requests, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE( + (SUM(input_tokens) * 0 + SUM(output_tokens) * 0) / 1000000, -- placeholder; real cost via model join if needed + 0 + ) AS est_cost_usd + FROM requests + WHERE created_at >= ? + GROUP BY provider + ORDER BY requests DESC + `, since.Format(time.RFC3339Nano)) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []ProviderBreakdown + for rows.Next() { + var pb ProviderBreakdown + if err := rows.Scan(&pb.Provider, &pb.Requests, &pb.InputTokens, &pb.OutputTokens, &pb.EstCostUSD); err != nil { + return nil, err + } + // Fallback rate calculation can be added later with scenario/fallback tracking + pb.FallbackRate = 0 + result = append(result, pb) + } + return result, rows.Err() +} + +// DailyTokenPoint is a single day in the token trend. +type DailyTokenPoint struct { + Date string `json:"date"` // YYYY-MM-DD + Requests int64 `json:"requests"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` +} + +// GetDailyTokenTrend returns daily token/request aggregates for the last N days. +func (a *Analytics) GetDailyTokenTrend(days int) ([]DailyTokenPoint, error) { + if days <= 0 { + days = 30 + } + since := time.Now().AddDate(0, 0, -days) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + rows, err := a.db.DB().QueryContext(ctx, ` + SELECT + DATE(created_at) AS day, + COUNT(*) AS requests, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens + FROM requests + WHERE created_at >= ? + GROUP BY DATE(created_at) + ORDER BY day ASC + `, since.Format(time.RFC3339Nano)) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []DailyTokenPoint + for rows.Next() { + var p DailyTokenPoint + if err := rows.Scan(&p.Date, &p.Requests, &p.InputTokens, &p.OutputTokens); err != nil { + return nil, err + } + result = append(result, p) + } + return result, rows.Err() +} From b776ac985b86e52fc2ab1a3b64be48f8713db47c Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 14:57:11 +0200 Subject: [PATCH 08/59] feat: Add analytics endpoints for summary, token trends, and latency stats --- internal/server/server.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/server/server.go b/internal/server/server.go index ccea04fb..abdf676c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -24,6 +24,7 @@ import ( "github.com/routatic/proxy/internal/status" "github.com/routatic/proxy/internal/storage" "github.com/routatic/proxy/internal/token" + "github.com/routatic/proxy/internal/gui" ) // Server represents the proxy server. @@ -135,6 +136,15 @@ func NewServer(atomic *config.AtomicConfig, captureLogger *debug.CaptureLogger) mux.HandleFunc("/health", healthHandler.HandleHealth) mux.HandleFunc("/statusline", healthHandler.HandleStatusline) + // Analytics endpoints (only when SQLite storage is available) + if db != nil { + analyticsStore := storage.NewAnalytics(db) + analyticsHandler := gui.NewAnalyticsHandler(analyticsStore) + mux.HandleFunc("/api/analytics/summary", analyticsHandler.Summary) + mux.HandleFunc("/api/analytics/tokens/trend", analyticsHandler.TokenTrend) + mux.HandleFunc("/api/analytics/latency", analyticsHandler.LatencyStats) + } + // Create HTTP server. addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) httpSrv := &http.Server{ From 31852e4867fd719fa4c6d58f96f68d47a7e698aa Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 15:07:55 +0200 Subject: [PATCH 09/59] feat: Implement analytics dashboard with update channel management and default model pricing --- cmd/routatic-proxy/update_channel.go | 79 +++++++++++ internal/gui/assets/app.js | 198 ++++++++++++++++++++++++++- internal/gui/assets/index.html | 67 +++++++++ internal/gui/assets/style.css | 170 ++++++++++++++++++++++- internal/handlers/messages.go | 2 + internal/history/record.go | 1 + internal/storage/analytics.go | 4 +- internal/storage/database.go | 62 +++++++++ internal/storage/requests.go | 20 ++- internal/storage/seed_prices.json | 23 ++++ internal/update/channel.go | 80 +++++++++++ 11 files changed, 698 insertions(+), 8 deletions(-) create mode 100644 cmd/routatic-proxy/update_channel.go create mode 100644 internal/storage/seed_prices.json create mode 100644 internal/update/channel.go diff --git a/cmd/routatic-proxy/update_channel.go b/cmd/routatic-proxy/update_channel.go new file mode 100644 index 00000000..aae1cacb --- /dev/null +++ b/cmd/routatic-proxy/update_channel.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "fmt" + + "github.com/routatic/proxy/internal/update" + "github.com/spf13/cobra" +) + +var updateChannelCmd = &cobra.Command{ + Use: "update-channel [stable|beta]", + Short: "Get or set the update channel (stable or beta)", + Long: `Get or set the update channel preference for self-updates. + +Channels: + stable - Production releases only (recommended for most users) + beta - Beta/nightly releases for early access to new features + +Examples: + # Show current channel + routatic-proxy update-channel + + # Switch to beta channel + routatic-proxy update-channel beta + + # Switch back to stable channel + routatic-proxy update-channel stable`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + // Get current channel + channel, err := update.LoadChannel() + if err != nil { + return fmt.Errorf("failed to load update channel: %w", err) + } + fmt.Printf("Current update channel: %s\n", channel) + if channel == update.ChannelBeta { + fmt.Println("\nYou are receiving beta releases.") + fmt.Println("To switch back to stable releases, run: routatic-proxy update-channel stable") + } else { + fmt.Println("\nYou are receiving stable (production) releases.") + fmt.Println("To receive beta releases, run: routatic-proxy update-channel beta") + } + return nil + } + + // Set channel + channelStr := args[0] + var channel update.Channel + switch channelStr { + case "stable": + channel = update.ChannelStable + case "beta": + channel = update.ChannelBeta + default: + return fmt.Errorf("invalid channel %q: must be 'stable' or 'beta'", channelStr) + } + + if err := update.SetChannel(channel); err != nil { + return fmt.Errorf("failed to set update channel: %w", err) + } + + fmt.Printf("Update channel set to: %s\n", channel) + if channel == update.ChannelBeta { + fmt.Println("\nYou will now receive beta releases when running 'routatic-proxy update'.") + fmt.Println("Beta releases may contain new features but could also have bugs.") + fmt.Println("To switch back to stable releases, run: routatic-proxy update-channel stable") + } else { + fmt.Println("\nYou 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 + }, +} + +func init() { + rootCmd.AddCommand(updateChannelCmd) +} diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 4dc10bc8..fa471eed 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -382,7 +382,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(); + } }); }); @@ -1075,7 +1079,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) @@ -1707,3 +1711,193 @@ const TestModule = { document.addEventListener('DOMContentLoaded', () => TestModule.init()); +/* ── Analytics Tab (minimal, vanilla JS + SVG/CSS) ─────────────── */ +const AnalyticsModule = { + loaded: false, + 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()); + if (refreshBtn) refreshBtn.addEventListener('click', () => this.load(true)); + }, + + async load(force = false) { + if (this.loaded && !force) return; + const daysEl = document.getElementById('analytics-days'); + const days = daysEl ? daysEl.value : 30; + const genEl = document.getElementById('analytics-generated'); + if (genEl) genEl.textContent = 'Loading…'; + + this.setLoading(true); + + 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: [] }; + + // merge latency into summary for KPI calc if needed + 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'}); + } + this.loaded = true; + } catch (e) { + console.error('Analytics error:', e); + this.renderEmpty('Failed to load analytics'); + if (genEl) genEl.textContent = 'Error'; + } finally { + this.setLoading(false); + } + }, + + setLoading(isLoading) { + ['model-donut','provider-donut','token-trend'].forEach(id => { + const el = document.getElementById(id); + if (el) el.style.opacity = isLoading ? '0.5' : ''; + }); + }, + + 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.total_tokens_input||0) + (s.total_tokens_output||0); + document.getElementById('kpi-tokens').textContent = fmt(totTok); + document.getElementById('kpi-tokens-in').textContent = fmt(s.total_tokens_input); + document.getElementById('kpi-tokens-out').textContent = fmt(s.total_tokens_output); + const cost = s.estimated_cost_usd != null ? '$' + Number(s.estimated_cost_usd).toFixed(2) : '—'; + document.getElementById('kpi-cost').textContent = cost; + + // p95 avg + 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 data
'; + 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; + legend.push(`
${this.escapeHtml(it.name||'Unknown')}${v}
`); + }); + + const html = `
${legend.join('')}
`; + wrap.innerHTML = html; + }, + + renderTrend(points) { + const wrap = document.getElementById('token-trend'); + if (!wrap) return; + wrap.innerHTML = ''; + if (!points.length) { + wrap.innerHTML = '
No trend data
'; + 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 data') { + ['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 + first load if tab already active) +setTimeout(() => { + AnalyticsModule.init(); + // If analytics tab is the initial active one (rare), load it + if (document.getElementById('tab-analytics')?.classList.contains('active')) { + AnalyticsModule.load(); + } +}, 250); + diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index d28883f6..ed1d3fe2 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -37,6 +37,7 @@ + @@ -269,6 +270,72 @@
+ +
+
+
+ Usage Analytics + +
+
+ + +
+
+ + +
+
+
Total Requests
+
--
+
requests
+
+
+
Total Tokens
+
--
+
0 in / 0 out
+
+
+
Est. Cost
+
--
+
USD (est.)
+
+
+
p95 Latency
+
--
+
ms across models
+
+
+ + +
+
+
Requests by Model
+
+
No data
+
+
+
+
Requests by Provider
+
+
No data
+
+
+
+ + +
+
Daily Token Trend
+
+
No trend data
+
+
+
+
diff --git a/internal/gui/assets/style.css b/internal/gui/assets/style.css index ac31b189..6eb83418 100644 --- a/internal/gui/assets/style.css +++ b/internal/gui/assets/style.css @@ -220,7 +220,175 @@ select:focus-visible { .model-name { flex: 0 0 160px; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .model-bar-wrap { flex: 1; height: 6px; background: var(--surface2); border-radius: 3px; overflow: hidden; } .model-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.4s ease; } -.model-count { flex: 0 0 40px; text-align: right; font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; } +.model-count { flex: 0 0 40px; text-align: right; font-size: 12px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +/* ── Analytics Tab ─────────────────────────────────────────────── */ +.analytics-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 12px; +} +.analytics-meta { + font-size: 12px; + color: var(--text-muted); + margin-left: 12px; +} +.analytics-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 12px; + margin-bottom: 24px; +} +.kpi-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 16px; + transition: transform 0.1s ease, box-shadow 0.1s ease; +} +.kpi-card:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} +.kpi-label { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 4px; + font-weight: 500; +} +.kpi-value { + font-size: 24px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--text); + line-height: 1.1; + margin: 2px 0; +} +.kpi-sub { + font-size: 11px; + color: var(--text-muted); +} + +.charts-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-bottom: 8px; +} +@media (max-width: 860px) { + .charts-row { grid-template-columns: 1fr; } +} + +.chart-container { + min-height: 240px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} +.trend-container { + min-height: 220px; + padding: 12px 16px; +} + +/* Donut chart (CSS conic-gradient based) */ +.donut-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} +.donut { + width: 160px; + height: 160px; + border-radius: 50%; + position: relative; + background: conic-gradient(var(--donut-segments)); + box-shadow: 0 2px 8px rgba(0,0,0,0.2); +} +.donut::after { + content: ''; + position: absolute; + top: 22px; + left: 22px; + width: 116px; + height: 116px; + background: var(--bg); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + color: var(--text); +} +.donut-legend { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + font-size: 12px; + max-width: 220px; + justify-content: center; +} +.legend-item { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} +.legend-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; +} +.legend-label { + color: var(--text); +} +.legend-value { + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +/* Trend line/area chart (SVG) */ +.trend-svg { + width: 100%; + height: 200px; + overflow: visible; +} +.trend-legend { + display: flex; + gap: 16px; + font-size: 12px; + margin-top: 8px; + justify-content: center; +} +.trend-legend span { + display: inline-flex; + align-items: center; + gap: 6px; +} +.trend-legend .swatch { + width: 12px; + height: 3px; + border-radius: 2px; +} /* ── History Table ─────────────────────────────────────────────── */ .history-toolbar { diff --git a/internal/handlers/messages.go b/internal/handlers/messages.go index 3276d8da..db1ca646 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -600,6 +600,7 @@ func (h *MessagesHandler) handleStreaming( OutputTokens: rw.usage.outputTokens, Streaming: true, Success: true, + Attempt: 1, // streaming fallback attempts not yet tracked in record; treat as primary } if h.history != nil { h.history.Add(rec) @@ -1196,6 +1197,7 @@ func (h *MessagesHandler) handleNonStreaming( OutputTokens: outputTokens, Streaming: false, Success: true, + Attempt: result.Attempted, } if h.history != nil { h.history.Add(rec) diff --git a/internal/history/record.go b/internal/history/record.go index f60a11dc..8f906816 100644 --- a/internal/history/record.go +++ b/internal/history/record.go @@ -16,4 +16,5 @@ type RequestRecord struct { Streaming bool // whether this was a streaming request Success bool // whether it completed successfully ErrorMsg string // error message if failed + Attempt int // attempt number in fallback chain (1 = primary, >1 = fallback) } diff --git a/internal/storage/analytics.go b/internal/storage/analytics.go index b16a4221..19377cdc 100644 --- a/internal/storage/analytics.go +++ b/internal/storage/analytics.go @@ -161,7 +161,9 @@ func (a *Analytics) GetProviderBreakdown(days int) ([]ProviderBreakdown, error) COALESCE( (SUM(input_tokens) * 0 + SUM(output_tokens) * 0) / 1000000, -- placeholder; real cost via model join if needed 0 - ) AS est_cost_usd + ) AS est_cost_usd, + -- Fallback rate: attempts > 1 are fallbacks; old rows (NULL/0) treated as primary (rate 0) + COALESCE(100.0 * COUNT(CASE WHEN COALESCE(attempt, 1) > 1 THEN 1 END) / NULLIF(COUNT(*), 0), 0) AS fallback_rate FROM requests WHERE created_at >= ? GROUP BY provider diff --git a/internal/storage/database.go b/internal/storage/database.go index e5f86853..acbb6fa3 100644 --- a/internal/storage/database.go +++ b/internal/storage/database.go @@ -4,6 +4,8 @@ package storage import ( "context" "database/sql" + _ "embed" + "encoding/json" "fmt" "os" "path/filepath" @@ -73,6 +75,10 @@ func Open(cfg Config) (*Database, error) { return nil, fmt.Errorf("init schema: %w", err) } + // Seed default model prices so analytics dashboard shows meaningful + // cost numbers immediately for new/existing installs. Idempotent. + _ = database.SeedDefaultModelPrices(ctx) + if cfg.VacuumOnStartup { if _, err := db.ExecContext(ctx, "VACUUM"); err != nil { _ = database.Close() @@ -97,6 +103,7 @@ func (d *Database) initSchema(ctx context.Context) error { streaming INTEGER, success INTEGER, error_msg TEXT, + attempt INTEGER DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -212,3 +219,58 @@ func expandPath(path string) string { } return path } + +// priceEntry defines a pricing rule for models whose id/name/display_name +// contains the match substring. Applied only if current cost is 0/NULL. +type priceEntry struct { + Match string `json:"match"` + Input float64 `json:"input"` + Output float64 `json:"output"` +} + +//go:embed seed_prices.json +var defaultModelPrices []byte + +// SeedDefaultModelPrices inserts realistic default pricing for common models +// (GLM, Kimi, Qwen, Grok, DeepSeek, Claude, GPT, MiniMax, Nemotron, MiMo, etc.) +// so that /api/analytics/* endpoints immediately show non-zero USD costs +// without requiring user config or catalog rates. +// +// The seeder is idempotent: it only updates rows where cost_input_per_m or +// cost_output_per_m is NULL or 0. This preserves any prices already set by +// catalog sync (internal/catalog) or user overrides. +// +// Prices are approximate current public list prices (per million tokens, USD) +// sourced from official provider documentation and pricing pages as of +// July 2026: OpenAI (GPT), Anthropic (Claude), Z.ai (GLM), Moonshot (Kimi), +// Alibaba (Qwen), xAI (Grok), DeepSeek, MiniMax, NVIDIA (Nemotron), and +// others. Free-tier variants are explicitly zeroed. Update seed_prices.json +// to refresh values; the JSON is embedded at build time. +func (d *Database) SeedDefaultModelPrices(ctx context.Context) error { + if len(defaultModelPrices) == 0 { + return nil + } + + var entries []priceEntry + if err := json.Unmarshal(defaultModelPrices, &entries); err != nil { + return fmt.Errorf("parse seed_prices.json: %w", err) + } + + for _, e := range entries { + if e.Match == "" { + continue + } + _, err := d.db.ExecContext(ctx, ` + UPDATE models + SET cost_input_per_m = ?, cost_output_per_m = ? + WHERE (cost_input_per_m IS NULL OR cost_input_per_m = 0) + AND (id LIKE '%' || ? || '%' + OR name LIKE '%' || ? || '%' + OR display_name LIKE '%' || ? || '%') + `, e.Input, e.Output, e.Match, e.Match, e.Match) + if err != nil { + return fmt.Errorf("seed price for %q: %w", e.Match, err) + } + } + return nil +} diff --git a/internal/storage/requests.go b/internal/storage/requests.go index 17e94f5f..0e285e2e 100644 --- a/internal/storage/requests.go +++ b/internal/storage/requests.go @@ -20,11 +20,15 @@ func (r *Requests) Insert(rec history.RequestRecord) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + attempt := rec.Attempt + if attempt < 1 { + attempt = 1 + } _, err := r.db.DB().ExecContext(ctx, ` INSERT OR REPLACE INTO requests ( id, model, provider, scenario, start_time, duration_ms, - input_tokens, output_tokens, streaming, success, error_msg - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + input_tokens, output_tokens, streaming, success, error_msg, attempt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, rec.ID, rec.Model, @@ -37,6 +41,7 @@ func (r *Requests) Insert(rec history.RequestRecord) error { boolToInt(rec.Streaming), boolToInt(rec.Success), rec.ErrorMsg, + attempt, ) return err @@ -52,7 +57,7 @@ func (r *Requests) Last(n int) ([]history.RequestRecord, error) { rows, err := r.db.DB().QueryContext(ctx, ` SELECT id, model, provider, scenario, start_time, duration_ms, - input_tokens, output_tokens, streaming, success, error_msg + input_tokens, output_tokens, streaming, success, error_msg, attempt FROM requests ORDER BY start_time DESC LIMIT ? @@ -71,7 +76,7 @@ func (r *Requests) Since(since time.Time) ([]history.RequestRecord, error) { rows, err := r.db.DB().QueryContext(ctx, ` SELECT id, model, provider, scenario, start_time, duration_ms, - input_tokens, output_tokens, streaming, success, error_msg + input_tokens, output_tokens, streaming, success, error_msg, attempt FROM requests WHERE start_time >= ? ORDER BY start_time DESC @@ -125,6 +130,7 @@ func scanRequests(rows *sql.Rows) ([]history.RequestRecord, error) { var startTimeStr string var streaming, success int + var attempt sql.NullInt64 err := rows.Scan( &rec.ID, &rec.Model, @@ -137,7 +143,13 @@ func scanRequests(rows *sql.Rows) ([]history.RequestRecord, error) { &streaming, &success, &rec.ErrorMsg, + &attempt, ) + if attempt.Valid { + rec.Attempt = int(attempt.Int64) + } else { + rec.Attempt = 1 + } if err != nil { return nil, err } diff --git a/internal/storage/seed_prices.json b/internal/storage/seed_prices.json new file mode 100644 index 00000000..b4818ced --- /dev/null +++ b/internal/storage/seed_prices.json @@ -0,0 +1,23 @@ +[ + {"match": "nemotron-3-ultra-free", "input": 0.00, "output": 0.00}, + {"match": "mimo-v2.5-free", "input": 0.00, "output": 0.00}, + {"match": "deepseek-v4-flash-free", "input": 0.00, "output": 0.00}, + {"match": "-free", "input": 0.00, "output": 0.00}, + {"match": "glm-5", "input": 0.55, "output": 2.20}, + {"match": "kimi-k2", "input": 0.95, "output": 3.20}, + {"match": "qwen3", "input": 0.25, "output": 1.00}, + {"match": "grok-4", "input": 4.50, "output": 14.00}, + {"match": "grok-build", "input": 0.10, "output": 0.40}, + {"match": "deepseek", "input": 0.28, "output": 1.12}, + {"match": "claude-3.5", "input": 3.00, "output": 15.00}, + {"match": "claude-sonnet", "input": 3.75, "output": 18.75}, + {"match": "claude-opus", "input": 15.00, "output": 75.00}, + {"match": "claude-haiku", "input": 0.80, "output": 3.20}, + {"match": "claude-fable", "input": 2.50, "output": 12.50}, + {"match": "gpt-4o", "input": 2.50, "output": 10.00}, + {"match": "gpt-5", "input": 6.00, "output": 24.00}, + {"match": "minimax-m", "input": 1.50, "output": 6.00}, + {"match": "minimax", "input": 1.20, "output": 4.80}, + {"match": "xai/grok", "input": 5.00, "output": 15.00}, + {"match": "openai/gpt", "input": 2.50, "output": 10.00} +] diff --git a/internal/update/channel.go b/internal/update/channel.go new file mode 100644 index 00000000..4c2f53d3 --- /dev/null +++ b/internal/update/channel.go @@ -0,0 +1,80 @@ +package update + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/routatic/proxy/internal/config" +) + +// Channel represents an update channel +type Channel string + +const ( + ChannelStable Channel = "stable" + ChannelBeta Channel = "beta" +) + +// DefaultChannel is the default update channel +const DefaultChannel = ChannelStable + +// StateFileName is the name of the file storing update channel preference +const StateFileName = "update-channel" + +// GetChannel returns the currently configured update channel +func GetChannel(cfg *config.Config) Channel { + // Check config first + if cfg.UpdateChannel != "" { + return Channel(cfg.UpdateChannel) + } + // Fall back to default + return DefaultChannel +} + +// SetChannel saves the update channel preference +func SetChannel(channel Channel) error { + configDir, err := getConfigDir() + if err != nil { + return fmt.Errorf("failed to get config directory: %w", err) + } + + stateFile := filepath.Join(configDir, StateFileName) + if err := os.WriteFile(stateFile, []byte(channel), 0644); err != nil { + return fmt.Errorf("failed to write update channel state: %w", err) + } + + return nil +} + +// LoadChannel loads the update channel from the state file +func LoadChannel() (Channel, error) { + configDir, err := getConfigDir() + if err != nil { + return DefaultChannel, nil // Return default if we can't determine config dir + } + + stateFile := filepath.Join(configDir, StateFileName) + data, err := os.ReadFile(stateFile) + if err != nil { + if os.IsNotExist(err) { + return DefaultChannel, nil + } + return DefaultChannel, fmt.Errorf("failed to read update channel state: %w", err) + } + + channel := Channel(string(data)) + if channel != ChannelStable && channel != ChannelBeta { + return DefaultChannel, nil + } + + return channel, nil +} + +func getConfigDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, ".config", "routatic-proxy"), nil +} From 9f216e8249b2bbb71cef6706b75067b6802776ce Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 16:34:53 +0200 Subject: [PATCH 10/59] feat: Implement update command with channel management and release fetching --- cmd/routatic-proxy/update.go | 163 +++++++++-------------- cmd/routatic-proxy/update_channel.go | 85 +++++------- internal/config/config.go | 1 + internal/gui/analytics.go | 15 ++- internal/gui/server.go | 8 ++ internal/handlers/messages.go | 22 +++- internal/server/server.go | 3 +- internal/storage/database.go | 23 ++++ internal/storage/requests.go | 1 + internal/update/channel.go | 101 ++++++++------ internal/update/update.go | 190 +++++++++++++++++++++++++++ 11 files changed, 407 insertions(+), 205 deletions(-) create mode 100644 internal/update/update.go diff --git a/cmd/routatic-proxy/update.go b/cmd/routatic-proxy/update.go index 0d0a136a..2c0ef784 100644 --- a/cmd/routatic-proxy/update.go +++ b/cmd/routatic-proxy/update.go @@ -2,112 +2,73 @@ 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 := update.GetChannel() + 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(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 index aae1cacb..fccdefa3 100644 --- a/cmd/routatic-proxy/update_channel.go +++ b/cmd/routatic-proxy/update_channel.go @@ -1,4 +1,4 @@ -package cmd +package main import ( "fmt" @@ -7,73 +7,58 @@ import ( "github.com/spf13/cobra" ) +// updateChannelCmd allows users to switch between stable and beta update channels var updateChannelCmd = &cobra.Command{ Use: "update-channel [stable|beta]", - Short: "Get or set the update channel (stable or beta)", - Long: `Get or set the update channel preference for self-updates. + Short: "Switch between stable and beta update channels", + Long: `Switch between stable (production) and beta (early access) update channels. -Channels: - stable - Production releases only (recommended for most users) - beta - Beta/nightly releases for early access to new features +When set to 'beta', the 'update' command will fetch pre-release versions +from GitHub instead of stable releases. Examples: - # Show current channel - routatic-proxy update-channel - - # Switch to beta channel - routatic-proxy update-channel beta - - # Switch back to stable channel - routatic-proxy update-channel stable`, + routatic-proxy update-channel beta # Switch to beta channel + routatic-proxy update-channel stable # Switch back to stable (default) + routatic-proxy update-channel # Show current channel`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { - // Get current channel - channel, err := update.LoadChannel() + // Show current channel + channel, err := update.GetChannel() if err != nil { - return fmt.Errorf("failed to load update channel: %w", err) + return fmt.Errorf("failed to read channel: %w", err) } fmt.Printf("Current update channel: %s\n", channel) - if channel == update.ChannelBeta { - fmt.Println("\nYou are receiving beta releases.") - fmt.Println("To switch back to stable releases, run: routatic-proxy update-channel stable") - } else { - fmt.Println("\nYou are receiving stable (production) releases.") + if channel == "stable" { fmt.Println("To receive beta releases, run: routatic-proxy update-channel beta") + } else { + fmt.Println("To receive stable (production) releases, run: routatic-proxy update-channel stable") } return nil } - // Set channel - channelStr := args[0] - var channel update.Channel - switch channelStr { - case "stable": - channel = update.ChannelStable - case "beta": - channel = update.ChannelBeta + channel := args[0] + switch channel { + case "stable", "beta": + if err := update.SetChannel(update.Channel(channel)); err != nil { + return fmt.Errorf("failed to set channel: %w", err) + } + fmt.Printf("Update channel set to: %s\n", channel) + if channel == "stable" { + 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") + } else { + fmt.Println("You will now receive beta releases when running 'routatic-proxy update'.") + fmt.Println("To receive stable (production) releases, run: routatic-proxy update-channel stable") + } + return nil default: - return fmt.Errorf("invalid channel %q: must be 'stable' or 'beta'", channelStr) - } - - if err := update.SetChannel(channel); err != nil { - return fmt.Errorf("failed to set update channel: %w", err) + return fmt.Errorf("invalid channel %q: must be 'stable' or 'beta'", channel) } - - fmt.Printf("Update channel set to: %s\n", channel) - if channel == update.ChannelBeta { - fmt.Println("\nYou will now receive beta releases when running 'routatic-proxy update'.") - fmt.Println("Beta releases may contain new features but could also have bugs.") - fmt.Println("To switch back to stable releases, run: routatic-proxy update-channel stable") - } else { - fmt.Println("\nYou 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 }, } -func init() { - rootCmd.AddCommand(updateChannelCmd) -} +// TODO: wire updateChannelCmd into rootCmd when root command registration is centralized. +// func init() { +// rootCmd.AddCommand(updateChannelCmd) +// } 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/gui/analytics.go b/internal/gui/analytics.go index a2040d6d..ada0577a 100644 --- a/internal/gui/analytics.go +++ b/internal/gui/analytics.go @@ -14,9 +14,10 @@ type AnalyticsHandler struct { store *storage.Analytics } -// NewAnalyticsHandler creates a handler backed by the given analytics store. -func NewAnalyticsHandler(store *storage.Analytics) *AnalyticsHandler { - return &AnalyticsHandler{store: store} +// NewAnalyticsHandler creates a handler backed by the given database. +// It internally creates an Analytics store. +func NewAnalyticsHandler(db *storage.Database) *AnalyticsHandler { + return &AnalyticsHandler{store: storage.NewAnalytics(db)} } func (h *AnalyticsHandler) writeJSON(w http.ResponseWriter, v any) { @@ -59,9 +60,9 @@ func (h *AnalyticsHandler) Summary(w http.ResponseWriter, r *http.Request) { } resp := map[string]any{ - "summary": summary, - "models": models, - "providers": providers, + "summary": summary, + "models": models, + "providers": providers, "generated_at": time.Now().Format(time.RFC3339), } h.writeJSON(w, resp) @@ -81,7 +82,7 @@ func (h *AnalyticsHandler) TokenTrend(w http.ResponseWriter, r *http.Request) { }) } -// LatencyStats returns latency stats per model (avg for now). +// LatencyStats returns latency stats per model. func (h *AnalyticsHandler) LatencyStats(w http.ResponseWriter, r *http.Request) { days := h.getDays(r) stats, err := h.store.GetModelBreakdown(days) diff --git a/internal/gui/server.go b/internal/gui/server.go index 086d3e72..d4c73d65 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -166,6 +166,14 @@ func (s *Server) Start(ctx context.Context) (string, error) { mux.HandleFunc("/api/perf/aggregate", s.handlePerformanceAggregate) mux.HandleFunc("/api/catalog/stats", s.handleCatalogStats) + // Analytics (only when SQLite storage is available) + if s.storage != nil { + ah := NewAnalyticsHandler(s.storage) + mux.HandleFunc("/api/analytics/summary", ah.Summary) + mux.HandleFunc("/api/analytics/tokens/trend", ah.TokenTrend) + mux.HandleFunc("/api/analytics/latency", ah.LatencyStats) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return "", fmt.Errorf("gui server listen: %w", err) diff --git a/internal/handlers/messages.go b/internal/handlers/messages.go index db1ca646..5a0749bf 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -205,6 +205,21 @@ func parseIntAfter(s string, start int) (int, error) { return sign * val, nil } +// isLowValueResponse decides whether a completed stream should be treated as a +// failure and trigger fallback. Currently this applies to long_context and +// complex scenarios when the model produced very little output. +func isLowValueResponse(scenario router.Scenario, outputTokens int, hasContent bool) bool { + if outputTokens >= 64 { + return false + } + switch scenario { + case router.ScenarioLongContext, router.ScenarioComplex: + return true + default: + return false + } +} + // headerWritten returns true if headers have been written to the response. // Safe for concurrent use. func (w *responseWriter) headerWritten() bool { @@ -703,9 +718,10 @@ func (h *MessagesHandler) handleStreaming( continue } - if !rw.hasContent() && rw.getOutputTokens() == 0 { - h.logger.Warn("upstream stream returned empty response, trying next model", - "model", model.ModelID, "provider", model.Provider) + if isLowValueResponse(scenario, rw.getOutputTokens(), rw.hasContent()) { + h.logger.Warn("upstream returned low-value response, triggering fallback", + "model", model.ModelID, "provider", model.Provider, + "scenario", scenario, "output_tokens", rw.getOutputTokens()) if !handleStreamError(transformer.ErrEmptyStream, model, wireFormat.String()) { return } diff --git a/internal/server/server.go b/internal/server/server.go index abdf676c..59c4294c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -138,8 +138,7 @@ func NewServer(atomic *config.AtomicConfig, captureLogger *debug.CaptureLogger) // Analytics endpoints (only when SQLite storage is available) if db != nil { - analyticsStore := storage.NewAnalytics(db) - analyticsHandler := gui.NewAnalyticsHandler(analyticsStore) + analyticsHandler := gui.NewAnalyticsHandler(db) mux.HandleFunc("/api/analytics/summary", analyticsHandler.Summary) mux.HandleFunc("/api/analytics/tokens/trend", analyticsHandler.TokenTrend) mux.HandleFunc("/api/analytics/latency", analyticsHandler.LatencyStats) diff --git a/internal/storage/database.go b/internal/storage/database.go index acbb6fa3..64544e86 100644 --- a/internal/storage/database.go +++ b/internal/storage/database.go @@ -7,8 +7,10 @@ import ( _ "embed" "encoding/json" "fmt" + "log/slog" "os" "path/filepath" + "strings" "sync" "time" @@ -75,6 +77,12 @@ func Open(cfg Config) (*Database, error) { return nil, fmt.Errorf("init schema: %w", err) } + // Lightweight migrations for new columns (safe on existing DBs) + if err := database.migrateAddAttemptColumn(ctx); err != nil { + // Non-fatal; log and continue so the proxy still works + slog.Warn("migration warning", "err", err) + } + // Seed default model prices so analytics dashboard shows meaningful // cost numbers immediately for new/existing installs. Idempotent. _ = database.SeedDefaultModelPrices(ctx) @@ -188,6 +196,21 @@ func (d *Database) initSchema(ctx context.Context) error { return err } +// migrateAddAttemptColumn adds the 'attempt' column to the requests table if it does not exist. +// This is used for fallback-rate analytics. +func (d *Database) migrateAddAttemptColumn(ctx context.Context) error { + // Try to add the column. SQLite will error if it already exists. + _, err := d.db.ExecContext(ctx, `ALTER TABLE requests ADD COLUMN attempt INTEGER DEFAULT 1`) + if err != nil { + // Ignore "duplicate column" errors + if strings.Contains(err.Error(), "duplicate column") { + return nil + } + return err + } + return nil +} + func (d *Database) Close() error { d.mu.Lock() defer d.mu.Unlock() diff --git a/internal/storage/requests.go b/internal/storage/requests.go index 0e285e2e..7cb47c5c 100644 --- a/internal/storage/requests.go +++ b/internal/storage/requests.go @@ -24,6 +24,7 @@ func (r *Requests) Insert(rec history.RequestRecord) error { if attempt < 1 { attempt = 1 } + _, err := r.db.DB().ExecContext(ctx, ` INSERT OR REPLACE INTO requests ( id, model, provider, scenario, start_time, duration_ms, diff --git a/internal/update/channel.go b/internal/update/channel.go index 4c2f53d3..d5c1b849 100644 --- a/internal/update/channel.go +++ b/internal/update/channel.go @@ -1,14 +1,12 @@ package update import ( - "fmt" + "encoding/json" "os" "path/filepath" - - "github.com/routatic/proxy/internal/config" ) -// Channel represents an update channel +// Channel represents the update channel preference type Channel string const ( @@ -16,65 +14,84 @@ const ( ChannelBeta Channel = "beta" ) -// DefaultChannel is the default update channel -const DefaultChannel = ChannelStable +// ChannelConfig stores the user's update channel preference +type ChannelConfig struct { + Channel Channel `json:"channel"` +} -// StateFileName is the name of the file storing update channel preference -const StateFileName = "update-channel" +// Default channel is stable +const DefaultChannel = ChannelStable -// GetChannel returns the currently configured update channel -func GetChannel(cfg *config.Config) Channel { - // Check config first - if cfg.UpdateChannel != "" { - return Channel(cfg.UpdateChannel) +// getChannelFilePath returns the path to the channel config file +func getChannelFilePath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", err } - // Fall back to default - return DefaultChannel + return filepath.Join(configDir, "routatic-proxy", "update-channel.json"), nil } -// SetChannel saves the update channel preference -func SetChannel(channel Channel) error { - configDir, err := getConfigDir() +// GetChannel reads the user's preferred update channel +// Returns DefaultChannel if no preference is set or file doesn't exist +func GetChannel() (Channel, error) { + path, err := getChannelFilePath() if err != nil { - return fmt.Errorf("failed to get config directory: %w", err) + return DefaultChannel, nil // Fall back to default on error + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return DefaultChannel, nil // No preference set yet + } + return DefaultChannel, nil // Fall back to default on read error + } + + var config ChannelConfig + if err := json.Unmarshal(data, &config); err != nil { + return DefaultChannel, nil // Fall back to default on parse error } - stateFile := filepath.Join(configDir, StateFileName) - if err := os.WriteFile(stateFile, []byte(channel), 0644); err != nil { - return fmt.Errorf("failed to write update channel state: %w", err) + // Validate channel value + if config.Channel != ChannelStable && config.Channel != ChannelBeta { + return DefaultChannel, nil } - return nil + return config.Channel, nil } -// LoadChannel loads the update channel from the state file -func LoadChannel() (Channel, error) { - configDir, err := getConfigDir() - if err != nil { - return DefaultChannel, nil // Return default if we can't determine config dir +// SetChannel saves the user's preferred update channel +func SetChannel(channel Channel) error { + // Validate channel value + if channel != ChannelStable && channel != ChannelBeta { + return os.ErrInvalid } - stateFile := filepath.Join(configDir, StateFileName) - data, err := os.ReadFile(stateFile) + path, err := getChannelFilePath() if err != nil { - if os.IsNotExist(err) { - return DefaultChannel, nil - } - return DefaultChannel, fmt.Errorf("failed to read update channel state: %w", err) + return err } - channel := Channel(string(data)) - if channel != ChannelStable && channel != ChannelBeta { - return DefaultChannel, nil + // Ensure directory exists + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + config := ChannelConfig{Channel: channel} + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err } - return channel, nil + return os.WriteFile(path, data, 0644) } -func getConfigDir() (string, error) { - homeDir, err := os.UserHomeDir() +// IsBeta returns true if the user has selected the beta channel +func IsBeta() bool { + channel, err := GetChannel() if err != nil { - return "", err + return false } - return filepath.Join(homeDir, ".config", "routatic-proxy"), nil + return channel == ChannelBeta } diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 00000000..9c307a73 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,190 @@ +package update + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// GitHubRelease represents a GitHub release +type GitHubRelease struct { + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +// GetLatestRelease fetches the latest release for the specified channel +func GetLatestRelease(channel Channel) (*GitHubRelease, error) { + var url string + + if channel == ChannelBeta { + // For beta, get all releases and find the latest prerelease with -beta- in tag + url = "https://api.github.com/repos/routatic/proxy/releases?per_page=20" + } else { + // For stable, use the /latest endpoint + url = "https://api.github.com/repos/routatic/proxy/releases/latest" + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch releases: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + if channel == ChannelBeta { + // Parse as array of releases + var releases []GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, fmt.Errorf("failed to parse releases: %w", err) + } + + // Find the latest beta release (first prerelease with -beta- in tag) + for _, release := range releases { + if release.Prerelease && strings.Contains(release.TagName, "-beta-") { + return &release, nil + } + } + return nil, fmt.Errorf("no beta releases found") + } + + // Parse as single release + var release GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("failed to parse release: %w", err) + } + + return &release, nil +} + +// GetAssetURL finds the download URL for the current platform +func GetAssetURL(release *GitHubRelease) (string, string, error) { + goos := runtime.GOOS + goarch := runtime.GOARCH + + // Map Go architecture to asset naming + archMap := map[string]string{ + "amd64": "amd64", + "arm64": "arm64", + } + + assetArch, ok := archMap[goarch] + if !ok { + return "", "", fmt.Errorf("unsupported architecture: %s", goarch) + } + + // Build expected asset name + // Format: routatic-proxy_{os}-{arch} or routatic-proxy_{os}-{arch}.exe for Windows + ext := "" + if goos == "windows" { + ext = ".exe" + } + + expectedName := fmt.Sprintf("routatic-proxy_%s-%s%s", goos, assetArch, ext) + + for _, asset := range release.Assets { + if asset.Name == expectedName { + return asset.BrowserDownloadURL, expectedName, nil + } + } + + return "", "", fmt.Errorf("no matching asset found for %s-%s", goos, goarch) +} + +// DownloadAndInstall downloads the binary and replaces the current executable +func DownloadAndInstall(url, filename string) error { + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("failed to download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + // Create temp file + tmpFile, err := os.CreateTemp("", "routatic-proxy-update-*") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + // Copy downloaded content to temp file + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + tmpFile.Close() + return fmt.Errorf("failed to write temp file: %w", err) + } + tmpFile.Close() + + // Make executable on Unix + if runtime.GOOS != "windows" { + if err := os.Chmod(tmpPath, 0755); err != nil { + return fmt.Errorf("failed to make executable: %w", err) + } + } + + // Get current executable path + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + // On Windows, we can't replace a running executable directly + // So we rename the old one and move the new one in place + if runtime.GOOS == "windows" { + oldPath := execPath + ".old" + // Remove any previous .old file + os.Remove(oldPath) + // Rename current executable + if err := os.Rename(execPath, oldPath); err != nil { + return fmt.Errorf("failed to rename current executable: %w", err) + } + // Move new executable into place + if err := os.Rename(tmpPath, execPath); err != nil { + // Try to restore old executable + os.Rename(oldPath, execPath) + return fmt.Errorf("failed to install new executable: %w", err) + } + // Clean up old executable + os.Remove(oldPath) + } else { + // On Unix, we can directly replace + if err := os.Rename(tmpPath, execPath); err != nil { + return fmt.Errorf("failed to replace executable: %w", err) + } + } + + return nil +} + +// CheckForUpdate checks if a newer version is available +func CheckForUpdate(currentVersion string, channel Channel) (*GitHubRelease, error) { + release, err := GetLatestRelease(channel) + if err != nil { + return nil, err + } + + // Compare versions (simple string comparison for now) + // In production, you'd want proper semantic version comparison + if release.TagName != currentVersion && release.TagName > currentVersion { + return release, nil + } + + return nil, nil // No update available +} From ff22c98710235020d0e26460b3e12ffc292a0f8e Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 16:59:14 +0200 Subject: [PATCH 11/59] feat: Refactor update channel command and improve channel preference handling --- cmd/routatic-proxy/main.go | 2 +- cmd/routatic-proxy/ui_darwin.go | 1 + cmd/routatic-proxy/ui_linux_nocgo.go | 1 + cmd/routatic-proxy/update.go | 5 +- cmd/routatic-proxy/update_channel.go | 57 +++++++++------- internal/gui/server.go | 98 +++++++++++++++++++++++++++- internal/storage/analytics.go | 4 +- internal/update/channel.go | 68 +++++++------------ internal/update/update.go | 11 ++-- 9 files changed, 164 insertions(+), 83 deletions(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 0aed47a8..808c79fa 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -54,7 +54,7 @@ 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()) + rootCmd.AddCommand(updateCmd) addPlatformCommands(rootCmd) if err := rootCmd.Execute(); err != nil { diff --git a/cmd/routatic-proxy/ui_darwin.go b/cmd/routatic-proxy/ui_darwin.go index 7dccda79..2f287cd3 100644 --- a/cmd/routatic-proxy/ui_darwin.go +++ b/cmd/routatic-proxy/ui_darwin.go @@ -336,6 +336,7 @@ Use the tray icon to reopen the window or quit entirely.`, StopProxy: stopProxy, CatalogDir: resolveCatalogDir(configPath), CatalogSourceURL: cfg.Catalog.SourceURL, + Storage: proxySrv.Storage(), }) guiSrv.SetProxyRunning(proxyInitiallyStarted) diff --git a/cmd/routatic-proxy/ui_linux_nocgo.go b/cmd/routatic-proxy/ui_linux_nocgo.go index 6eb4e879..55c82a29 100644 --- a/cmd/routatic-proxy/ui_linux_nocgo.go +++ b/cmd/routatic-proxy/ui_linux_nocgo.go @@ -227,6 +227,7 @@ Press Ctrl+C to stop.`, StopProxy: stopProxy, CatalogDir: resolveCatalogDir(configPath), CatalogSourceURL: cfg.Catalog.SourceURL, + Storage: proxySrv.Storage(), }) // Set the connected flag now that guiSrv exists diff --git a/cmd/routatic-proxy/update.go b/cmd/routatic-proxy/update.go index 2c0ef784..b0fd2fe6 100644 --- a/cmd/routatic-proxy/update.go +++ b/cmd/routatic-proxy/update.go @@ -20,7 +20,10 @@ Examples: routatic-proxy update check # Check for updates without installing`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - channel := update.GetChannel() + 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) diff --git a/cmd/routatic-proxy/update_channel.go b/cmd/routatic-proxy/update_channel.go index fccdefa3..6da65334 100644 --- a/cmd/routatic-proxy/update_channel.go +++ b/cmd/routatic-proxy/update_channel.go @@ -7,54 +7,63 @@ import ( "github.com/spf13/cobra" ) -// updateChannelCmd allows users to switch between stable and beta update channels var updateChannelCmd = &cobra.Command{ Use: "update-channel [stable|beta]", - Short: "Switch between stable and beta update channels", - Long: `Switch between stable (production) and beta (early access) update channels. + Short: "Get or set the update channel preference", + Long: `View or change your preferred update channel. -When set to 'beta', the 'update' command will fetch pre-release versions -from GitHub instead of stable releases. +Channels: + stable - Production releases only (default) + beta - Beta releases from main branch Examples: - routatic-proxy update-channel beta # Switch to beta channel - routatic-proxy update-channel stable # Switch back to stable (default) - routatic-proxy update-channel # Show current channel`, + # Show current channel + routatic-proxy update-channel + + # Switch to beta channel + routatic-proxy update-channel beta + + # Switch back to stable + routatic-proxy update-channel stable`, 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 read channel: %w", err) + return fmt.Errorf("failed to get channel: %w", err) } fmt.Printf("Current update channel: %s\n", channel) if channel == "stable" { + fmt.Println("You will receive stable (production) releases when running 'routatic-proxy update'.") fmt.Println("To receive beta releases, run: routatic-proxy update-channel beta") } else { + fmt.Println("You will receive beta releases when running 'routatic-proxy update'.") fmt.Println("To receive stable (production) releases, run: routatic-proxy update-channel stable") } return nil } channel := args[0] - switch channel { - case "stable", "beta": - if err := update.SetChannel(update.Channel(channel)); err != nil { - return fmt.Errorf("failed to set channel: %w", err) - } - fmt.Printf("Update channel set to: %s\n", channel) - if channel == "stable" { - 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") - } else { - fmt.Println("You will now receive beta releases when running 'routatic-proxy update'.") - fmt.Println("To receive stable (production) releases, run: routatic-proxy update-channel stable") - } - return nil - default: + if channel != "stable" && channel != "beta" { return fmt.Errorf("invalid channel %q: must be 'stable' or 'beta'", channel) } + + if err := update.SetChannel(channel); err != nil { + return fmt.Errorf("failed to set channel: %w", err) + } + + if channel == "stable" { + fmt.Println("Update channel set to: stable") + 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") + } else { + fmt.Println("Update channel set to: beta") + fmt.Println("You will now receive beta releases when running 'routatic-proxy update'.") + fmt.Println("To receive stable (production) releases, run: routatic-proxy update-channel stable") + } + + return nil }, } diff --git a/internal/gui/server.go b/internal/gui/server.go index d4c73d65..c1a3fc71 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -12,8 +12,11 @@ import ( "net" "net/http" "os" + "os/exec" "path/filepath" "runtime" + "strconv" + "strings" "sync" "sync/atomic" "time" @@ -136,9 +139,17 @@ func (s *Server) getProxyPort() int { return s.proxyPort } -// Start starts the embedded HTTP server on a random localhost port and returns -// the URL that the webview should load. +const guiPort = 3445 + +// Start starts the embedded HTTP server on port 3445 and returns +// the URL that the webview should load. If another routatic-proxy instance +// is using that port, it is killed before binding. func (s *Server) Start(ctx context.Context) (string, error) { + // Ensure port 3445 is free, killing any existing routatic-proxy GUI. + if err := s.ensurePortAvailable(guiPort); err != nil { + return "", fmt.Errorf("gui port check: %w", err) + } + mux := http.NewServeMux() // Static assets — strip the "assets/" prefix so index.html is served at /. @@ -174,7 +185,7 @@ func (s *Server) Start(ctx context.Context) (string, error) { mux.HandleFunc("/api/analytics/latency", ah.LatencyStats) } - ln, err := net.Listen("tcp", "127.0.0.1:0") + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", guiPort)) if err != nil { return "", fmt.Errorf("gui server listen: %w", err) } @@ -557,6 +568,87 @@ func writeJSON(w http.ResponseWriter, v any) { _ = json.NewEncoder(w).Encode(v) } +// ensurePortAvailable checks if port 3445 is in use by another routatic-proxy +// instance and kills it. Returns an error if the port is in use by a different process. +func (s *Server) ensurePortAvailable(port int) error { + conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + // Port is free + return nil + } + _ = conn.Close() + + // Port is in use - check if it's us via the /api/metrics endpoint + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/metrics", port)) + if err != nil { + return fmt.Errorf("port %d in use by unknown process (no HTTP response)", port) + } + defer resp.Body.Close() + + // Check if this is our GUI server by looking for proxy_running field + var m struct { + ProxyRunning bool `json:"proxy_running"` + } + if err := json.NewDecoder(resp.Body).Decode(&m); err == nil { + // This is our GUI server - kill it + s.logger.Info("killing existing routatic-proxy GUI on port", "port", port) + return s.killProcessOnPort(port) + } + + return fmt.Errorf("port %d in use by unknown process (not routatic-proxy)", port) +} + +// killProcessOnPort terminates the process listening on the given port. +func (s *Server) killProcessOnPort(port int) error { + // Use lsof to find the PID + cmd := exec.Command("lsof", "-t", "-i", fmt.Sprintf(":%d", port)) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to find process on port %d: %w", port, err) + } + + pids := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, pidStr := range pids { + if pidStr == "" { + continue + } + pid, err := strconv.Atoi(pidStr) + if err != nil { + continue + } + + // Verify it's routatic-proxy before killing + cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + if err == nil && strings.Contains(string(cmdline), "routatic-proxy") { + s.logger.Info("terminating routatic-proxy process", "pid", pid) + p, err := os.FindProcess(pid) + if err == nil { + _ = p.Signal(os.Interrupt) + // Wait briefly for graceful shutdown + time.Sleep(500 * time.Millisecond) + // Force kill if still running + _ = p.Kill() + } + } else { + return fmt.Errorf("port %d in use by non-routatic-proxy process (pid %d)", port, pid) + } + } + + // Wait for port to be released + for i := 0; i < 10; i++ { + conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + // Port is now free + return nil + } + _ = conn.Close() + time.Sleep(100 * time.Millisecond) + } + + return fmt.Errorf("port %d not released after killing process", port) +} + // securityHeadersMiddleware adds security headers to all responses. func securityHeadersMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/storage/analytics.go b/internal/storage/analytics.go index 19377cdc..3b7ce135 100644 --- a/internal/storage/analytics.go +++ b/internal/storage/analytics.go @@ -177,11 +177,9 @@ func (a *Analytics) GetProviderBreakdown(days int) ([]ProviderBreakdown, error) var result []ProviderBreakdown for rows.Next() { var pb ProviderBreakdown - if err := rows.Scan(&pb.Provider, &pb.Requests, &pb.InputTokens, &pb.OutputTokens, &pb.EstCostUSD); err != nil { + if err := rows.Scan(&pb.Provider, &pb.Requests, &pb.InputTokens, &pb.OutputTokens, &pb.EstCostUSD, &pb.FallbackRate); err != nil { return nil, err } - // Fallback rate calculation can be added later with scenario/fallback tracking - pb.FallbackRate = 0 result = append(result, pb) } return result, rows.Err() diff --git a/internal/update/channel.go b/internal/update/channel.go index d5c1b849..444c05d4 100644 --- a/internal/update/channel.go +++ b/internal/update/channel.go @@ -6,54 +6,48 @@ import ( "path/filepath" ) -// Channel represents the update channel preference -type Channel string - -const ( - ChannelStable Channel = "stable" - ChannelBeta Channel = "beta" -) - // ChannelConfig stores the user's update channel preference type ChannelConfig struct { - Channel Channel `json:"channel"` + Channel string `json:"channel"` // "stable" or "beta" } -// Default channel is stable -const DefaultChannel = ChannelStable +// DefaultChannel is the default update channel +const DefaultChannel = "stable" -// getChannelFilePath returns the path to the channel config file -func getChannelFilePath() (string, error) { - configDir, err := os.UserConfigDir() +// GetChannelConfigPath returns the path to the channel config file +func GetChannelConfigPath() (string, error) { + homeDir, err := os.UserHomeDir() if err != nil { return "", err } - return filepath.Join(configDir, "routatic-proxy", "update-channel.json"), nil + configDir := filepath.Join(homeDir, ".config", "routatic-proxy") + if err := os.MkdirAll(configDir, 0755); err != nil { + return "", err + } + return filepath.Join(configDir, "update-channel.json"), nil } // GetChannel reads the user's preferred update channel -// Returns DefaultChannel if no preference is set or file doesn't exist -func GetChannel() (Channel, error) { - path, err := getChannelFilePath() +func GetChannel() (string, error) { + configPath, err := GetChannelConfigPath() if err != nil { - return DefaultChannel, nil // Fall back to default on error + return DefaultChannel, nil // Return default on error } - data, err := os.ReadFile(path) + data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { - return DefaultChannel, nil // No preference set yet + return DefaultChannel, nil // Default to stable } - return DefaultChannel, nil // Fall back to default on read error + return DefaultChannel, nil } var config ChannelConfig if err := json.Unmarshal(data, &config); err != nil { - return DefaultChannel, nil // Fall back to default on parse error + return DefaultChannel, nil } - // Validate channel value - if config.Channel != ChannelStable && config.Channel != ChannelBeta { + if config.Channel != "stable" && config.Channel != "beta" { return DefaultChannel, nil } @@ -61,37 +55,21 @@ func GetChannel() (Channel, error) { } // SetChannel saves the user's preferred update channel -func SetChannel(channel Channel) error { - // Validate channel value - if channel != ChannelStable && channel != ChannelBeta { +func SetChannel(channel string) error { + if channel != "stable" && channel != "beta" { return os.ErrInvalid } - path, err := getChannelFilePath() + configPath, err := GetChannelConfigPath() if err != nil { return err } - // Ensure directory exists - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - config := ChannelConfig{Channel: channel} data, err := json.MarshalIndent(config, "", " ") if err != nil { return err } - return os.WriteFile(path, data, 0644) -} - -// IsBeta returns true if the user has selected the beta channel -func IsBeta() bool { - channel, err := GetChannel() - if err != nil { - return false - } - return channel == ChannelBeta + return os.WriteFile(configPath, data, 0644) } diff --git a/internal/update/update.go b/internal/update/update.go index 9c307a73..30ef120d 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "os" - "path/filepath" "runtime" "strings" "time" @@ -22,11 +21,11 @@ type GitHubRelease struct { } `json:"assets"` } -// GetLatestRelease fetches the latest release for the specified channel -func GetLatestRelease(channel Channel) (*GitHubRelease, error) { +// GetLatestRelease fetches the latest release for the specified channel ("stable" or "beta") +func GetLatestRelease(channel string) (*GitHubRelease, error) { var url string - if channel == ChannelBeta { + if channel == "beta" { // For beta, get all releases and find the latest prerelease with -beta- in tag url = "https://api.github.com/repos/routatic/proxy/releases?per_page=20" } else { @@ -45,7 +44,7 @@ func GetLatestRelease(channel Channel) (*GitHubRelease, error) { return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) } - if channel == ChannelBeta { + if channel == "beta" { // Parse as array of releases var releases []GitHubRelease if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { @@ -174,7 +173,7 @@ func DownloadAndInstall(url, filename string) error { } // CheckForUpdate checks if a newer version is available -func CheckForUpdate(currentVersion string, channel Channel) (*GitHubRelease, error) { +func CheckForUpdate(currentVersion string, channel string) (*GitHubRelease, error) { release, err := GetLatestRelease(channel) if err != nil { return nil, err From 11773e384b0e9c2bf7de84dafd959b7c1eefcc8a Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 17:15:06 +0200 Subject: [PATCH 12/59] feat: Implement start command to launch proxy server with GUI dashboard and refactor update channel command for clarity --- cmd/routatic-proxy/main.go | 141 ++++++++- cmd/routatic-proxy/ui_darwin.go | 411 -------------------------- cmd/routatic-proxy/ui_darwin_nocgo.go | 13 - cmd/routatic-proxy/ui_linux_nocgo.go | 261 ---------------- cmd/routatic-proxy/ui_other.go | 9 - cmd/routatic-proxy/update_channel.go | 42 +-- internal/gui/server.go | 107 ++++--- internal/update/channel.go | 37 ++- 8 files changed, 250 insertions(+), 771 deletions(-) delete mode 100644 cmd/routatic-proxy/ui_darwin.go delete mode 100644 cmd/routatic-proxy/ui_darwin_nocgo.go delete mode 100644 cmd/routatic-proxy/ui_linux_nocgo.go delete mode 100644 cmd/routatic-proxy/ui_other.go diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 808c79fa..896d9c90 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" @@ -55,7 +59,7 @@ Legacy ~/.config/oc-go-cc/config.json and OC_GO_CC_* environment variables are s rootCmd.AddCommand(catalogCmd()) rootCmd.AddCommand(autostartCmd()) rootCmd.AddCommand(updateCmd) - addPlatformCommands(rootCmd) + rootCmd.AddCommand(startCmd()) if err := rootCmd.Execute(); err != nil { os.Exit(1) @@ -216,6 +220,141 @@ 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 + + cmd := &cobra.Command{ + Use: "start", + Short: "Start the proxy server with dashboard", + Long: `Start the proxy server and GUI dashboard together. + +The proxy runs on the configured port (default 3456), and the dashboard +is available at http://127.0.0.1:3445. All usage data is persisted to +SQLite regardless of whether the dashboard is open. + +Press Ctrl+C to stop both servers.`, + RunE: func(cmd *cobra.Command, args []string) error { + // 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 + } + + // 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() + + // Start proxy in background. + go func() { + if err := srv.Start(); err != nil && err != http.ErrServerClosed { + slog.Error("proxy server error", "error", err) + cancel() + } + }() + + // 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) + } + + // 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") + } + fmt.Println() + fmt.Printf("Dashboard: %s\n", guiURL) + fmt.Println("\nPress 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) + _ = 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") + + return cmd +} + // stopCmd returns the command to stop the proxy server. func stopCmd() *cobra.Command { return &cobra.Command{ diff --git a/cmd/routatic-proxy/ui_darwin.go b/cmd/routatic-proxy/ui_darwin.go deleted file mode 100644 index 2f287cd3..00000000 --- a/cmd/routatic-proxy/ui_darwin.go +++ /dev/null @@ -1,411 +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, - Storage: proxySrv.Storage(), - }) - 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 55c82a29..00000000 --- a/cmd/routatic-proxy/ui_linux_nocgo.go +++ /dev/null @@ -1,261 +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, - Storage: proxySrv.Storage(), - }) - - // 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_channel.go b/cmd/routatic-proxy/update_channel.go index 6da65334..c60d369a 100644 --- a/cmd/routatic-proxy/update_channel.go +++ b/cmd/routatic-proxy/update_channel.go @@ -9,38 +9,28 @@ import ( var updateChannelCmd = &cobra.Command{ Use: "update-channel [stable|beta]", - Short: "Get or set the update channel preference", - Long: `View or change your preferred update channel. + Short: "Switch between stable and beta update channels", + Long: `Choose which release channel to receive updates from. -Channels: - stable - Production releases only (default) - beta - Beta releases from main branch +Stable channel (default): Receives production releases only +Beta channel: Receives early access beta releases for testing Examples: - # Show current channel - routatic-proxy update-channel - - # Switch to beta channel - routatic-proxy update-channel beta - - # Switch back to stable - routatic-proxy update-channel stable`, + routatic-proxy update-channel # Show current channel + routatic-proxy update-channel stable # Switch to stable releases + routatic-proxy update-channel beta # Switch to beta releases`, 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 channel: %w", err) + return fmt.Errorf("failed to get update channel: %w", err) } fmt.Printf("Current update channel: %s\n", channel) - if channel == "stable" { - fmt.Println("You will receive stable (production) releases when running 'routatic-proxy update'.") - fmt.Println("To receive beta releases, run: routatic-proxy update-channel beta") - } else { - fmt.Println("You will receive beta releases when running 'routatic-proxy update'.") - fmt.Println("To receive stable (production) releases, run: routatic-proxy update-channel stable") - } + fmt.Println("\nTo switch channels:") + fmt.Println(" routatic-proxy update-channel stable") + fmt.Println(" routatic-proxy update-channel beta") return nil } @@ -50,17 +40,17 @@ Examples: } if err := update.SetChannel(channel); err != nil { - return fmt.Errorf("failed to set channel: %w", err) + return fmt.Errorf("failed to set update channel: %w", err) } if channel == "stable" { - fmt.Println("Update channel set to: stable") - fmt.Println("You will now receive stable (production) releases when running 'routatic-proxy update'.") + fmt.Println("Switched to stable (production) release channel.") + fmt.Println("You will now receive stable releases when running 'routatic-proxy update'.") fmt.Println("To receive beta releases, run: routatic-proxy update-channel beta") } else { - fmt.Println("Update channel set to: beta") + fmt.Println("Switched to beta release channel.") fmt.Println("You will now receive beta releases when running 'routatic-proxy update'.") - fmt.Println("To receive stable (production) releases, run: routatic-proxy update-channel stable") + fmt.Println("To receive stable releases, run: routatic-proxy update-channel stable") } return nil diff --git a/internal/gui/server.go b/internal/gui/server.go index c1a3fc71..9510e94f 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -139,7 +139,7 @@ func (s *Server) getProxyPort() int { return s.proxyPort } -const guiPort = 3445 +var guiPort = 3445 // Start starts the embedded HTTP server on port 3445 and returns // the URL that the webview should load. If another routatic-proxy instance @@ -208,6 +208,14 @@ func (s *Server) Start(ctx context.Context) (string, error) { return url, nil } +// Shutdown gracefully stops the GUI server. +func (s *Server) Shutdown(ctx context.Context) error { + if s.srv == nil { + return nil + } + return s.srv.Shutdown(ctx) +} + // ── API handlers ────────────────────────────────────────────────────────────── type metricsResponse struct { @@ -568,35 +576,59 @@ func writeJSON(w http.ResponseWriter, v any) { _ = json.NewEncoder(w).Encode(v) } -// ensurePortAvailable checks if port 3445 is in use by another routatic-proxy -// instance and kills it. Returns an error if the port is in use by a different process. -func (s *Server) ensurePortAvailable(port int) error { - conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port)) - if err != nil { - // Port is free - return nil - } - _ = conn.Close() - - // Port is in use - check if it's us via the /api/metrics endpoint +// ensurePortAvailable finds an available port for the GUI. +// If 3445 is free, uses it. If used by another routatic-proxy, kills it. +// If used by a different app, increments port (up to 3454) and notifies user. +func (s *Server) ensurePortAvailable(startPort int) error { client := &http.Client{Timeout: 2 * time.Second} - resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/metrics", port)) - if err != nil { - return fmt.Errorf("port %d in use by unknown process (no HTTP response)", port) - } - defer resp.Body.Close() - // Check if this is our GUI server by looking for proxy_running field - var m struct { - ProxyRunning bool `json:"proxy_running"` - } - if err := json.NewDecoder(resp.Body).Decode(&m); err == nil { - // This is our GUI server - kill it - s.logger.Info("killing existing routatic-proxy GUI on port", "port", port) - return s.killProcessOnPort(port) + for p := startPort; p < startPort+10; p++ { + // Try to bind + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) + if err == nil { + _ = ln.Close() + guiPort = p + return nil + } + + // Port in use - check if it's our GUI + resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/metrics", p)) + if err != nil { + // No HTTP response → another app is using it + if p == startPort { + fmt.Printf("Port %d in use by another app, trying port %d for GUI\n", p, p+1) + } + continue + } + defer resp.Body.Close() + + var m struct { + ProxyRunning bool `json:"proxy_running"` + } + if err := json.NewDecoder(resp.Body).Decode(&m); err == nil { + // Our GUI - kill the existing instance + s.logger.Info("killing existing routatic-proxy GUI on port", "port", p) + if err := s.killProcessOnPort(p); err != nil { + return fmt.Errorf("failed to kill existing instance: %w", err) + } + // Try bind again after kill + ln2, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) + if err == nil { + _ = ln2.Close() + guiPort = p + return nil + } + // Still blocked, continue to next port + continue + } + + // Responded but not our metrics format → another app + if p == startPort { + fmt.Printf("Port %d in use by another app, trying port %d for GUI\n", p, p+1) + } } - return fmt.Errorf("port %d in use by unknown process (not routatic-proxy)", port) + return fmt.Errorf("no available GUI port in range %d-%d", startPort, startPort+9) } // killProcessOnPort terminates the process listening on the given port. @@ -605,10 +637,12 @@ func (s *Server) killProcessOnPort(port int) error { cmd := exec.Command("lsof", "-t", "-i", fmt.Sprintf(":%d", port)) output, err := cmd.Output() if err != nil { - return fmt.Errorf("failed to find process on port %d: %w", port, err) + // lsof failed or returned nothing - port might be free now + return nil } pids := strings.Split(strings.TrimSpace(string(output)), "\n") + killed := false for _, pidStr := range pids { if pidStr == "" { continue @@ -620,21 +654,26 @@ func (s *Server) killProcessOnPort(port int) error { // Verify it's routatic-proxy before killing cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) - if err == nil && strings.Contains(string(cmdline), "routatic-proxy") { + if err != nil { + continue + } + // cmdline uses null bytes as separators + if strings.Contains(string(cmdline), "routatic-proxy") { s.logger.Info("terminating routatic-proxy process", "pid", pid) - p, err := os.FindProcess(pid) - if err == nil { + p, _ := os.FindProcess(pid) + if p != nil { _ = p.Signal(os.Interrupt) - // Wait briefly for graceful shutdown time.Sleep(500 * time.Millisecond) - // Force kill if still running _ = p.Kill() + killed = true } - } else { - return fmt.Errorf("port %d in use by non-routatic-proxy process (pid %d)", port, pid) } } + if !killed { + return fmt.Errorf("no routatic-proxy process found on port %d", port) + } + // Wait for port to be released for i := 0; i < 10; i++ { conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port)) diff --git a/internal/update/channel.go b/internal/update/channel.go index 444c05d4..a92ba4ba 100644 --- a/internal/update/channel.go +++ b/internal/update/channel.go @@ -14,41 +14,40 @@ type ChannelConfig struct { // DefaultChannel is the default update channel const DefaultChannel = "stable" -// GetChannelConfigPath returns the path to the channel config file -func GetChannelConfigPath() (string, error) { - homeDir, err := os.UserHomeDir() +// ConfigFileName is the name of the channel config file +const ConfigFileName = "update-channel.json" + +// GetConfigPath returns the path to the channel config file +func GetConfigPath() (string, error) { + configDir, err := os.UserConfigDir() if err != nil { return "", err } - configDir := filepath.Join(homeDir, ".config", "routatic-proxy") - if err := os.MkdirAll(configDir, 0755); err != nil { - return "", err - } - return filepath.Join(configDir, "update-channel.json"), nil + return filepath.Join(configDir, "routatic-proxy", ConfigFileName), nil } // GetChannel reads the user's preferred update channel func GetChannel() (string, error) { - configPath, err := GetChannelConfigPath() + configPath, err := GetConfigPath() if err != nil { - return DefaultChannel, nil // Return default on error + return DefaultChannel, nil // Fall back to default if we can't determine path } data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { - return DefaultChannel, nil // Default to stable + return DefaultChannel, nil // No preference set, use default } - return DefaultChannel, nil + return "", err } var config ChannelConfig if err := json.Unmarshal(data, &config); err != nil { - return DefaultChannel, nil + return DefaultChannel, nil // Corrupted config, use default } if config.Channel != "stable" && config.Channel != "beta" { - return DefaultChannel, nil + return DefaultChannel, nil // Invalid channel, use default } return config.Channel, nil @@ -57,14 +56,20 @@ func GetChannel() (string, error) { // SetChannel saves the user's preferred update channel func SetChannel(channel string) error { if channel != "stable" && channel != "beta" { - return os.ErrInvalid + return nil // Silently ignore invalid channels } - configPath, err := GetChannelConfigPath() + configPath, err := GetConfigPath() if err != nil { return err } + // Ensure directory exists + dir := filepath.Dir(configPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + config := ChannelConfig{Channel: channel} data, err := json.MarshalIndent(config, "", " ") if err != nil { From 998916ca7ce099b5f492e841b7a73df867f76ea5 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 17:26:04 +0200 Subject: [PATCH 13/59] feat: Enhance update channel management and add background daemon support --- CLAUDE.md | 31 ++++++------ cmd/routatic-proxy/main.go | 58 +++++++++++++++++++++- cmd/routatic-proxy/update_channel.go | 36 ++++++-------- internal/daemon/background.go | 11 +++-- internal/update/channel.go | 74 ++++++++++++++++------------ 5 files changed, 137 insertions(+), 73 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fd421769..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 diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 896d9c90..fef7c1be 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -34,7 +34,6 @@ const ( var version = "dev" func main() { - setupDefaultCommand() rootCmd := &cobra.Command{ Use: appName, Aliases: []string{"oc-go-cc"}, @@ -224,6 +223,8 @@ func serveCmd() *cobra.Command { func startCmd() *cobra.Command { var configPath string var port int + var background bool + var daemonize bool // hidden internal flag cmd := &cobra.Command{ Use: "start", @@ -236,6 +237,16 @@ SQLite regardless of whether the dashboard is open. Press Ctrl+C to stop both servers.`, 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) @@ -260,6 +271,48 @@ Press Ctrl+C to stop both servers.`, 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()) @@ -351,6 +404,9 @@ Press Ctrl+C to stop both servers.`, 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().BoolVar(&daemonize, "_daemonize", false, "Internal use only") + _ = cmd.Flags().MarkHidden("_daemonize") return cmd } diff --git a/cmd/routatic-proxy/update_channel.go b/cmd/routatic-proxy/update_channel.go index c60d369a..b5212ef0 100644 --- a/cmd/routatic-proxy/update_channel.go +++ b/cmd/routatic-proxy/update_channel.go @@ -10,15 +10,15 @@ import ( var updateChannelCmd = &cobra.Command{ Use: "update-channel [stable|beta]", Short: "Switch between stable and beta update channels", - Long: `Choose which release channel to receive updates from. + Long: `Switch between stable (production) and beta (early access) update channels. -Stable channel (default): Receives production releases only -Beta channel: Receives early access beta releases for testing +By default, updates come from the stable channel. Use this command to opt into +beta releases for early access to new features. Examples: - routatic-proxy update-channel # Show current channel - routatic-proxy update-channel stable # Switch to stable releases - routatic-proxy update-channel beta # Switch to beta releases`, + 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 { @@ -29,28 +29,24 @@ Examples: } fmt.Printf("Current update channel: %s\n", channel) fmt.Println("\nTo switch channels:") - fmt.Println(" routatic-proxy update-channel stable") - fmt.Println(" routatic-proxy update-channel beta") + fmt.Println(" routatic-proxy update-channel beta # Early access releases") + fmt.Println(" routatic-proxy update-channel stable # Production releases (default)") return nil } - channel := args[0] - if channel != "stable" && channel != "beta" { - return fmt.Errorf("invalid channel %q: must be 'stable' or 'beta'", channel) - } - + channel := update.Channel(args[0]) if err := update.SetChannel(channel); err != nil { return fmt.Errorf("failed to set update channel: %w", err) } - if channel == "stable" { - fmt.Println("Switched to stable (production) release channel.") - fmt.Println("You will now receive stable releases when running 'routatic-proxy update'.") - fmt.Println("To receive beta releases, run: routatic-proxy update-channel beta") + if channel == update.ChannelBeta { + fmt.Println("Switched to beta update channel.") + fmt.Println("You will now receive beta (early access) releases when running 'routatic-proxy update'.") + fmt.Println("To switch back to stable releases, run: routatic-proxy update-channel stable") } else { - fmt.Println("Switched to beta release channel.") - fmt.Println("You will now receive beta releases when running 'routatic-proxy update'.") - fmt.Println("To receive stable releases, run: routatic-proxy update-channel stable") + fmt.Println("Switched to stable update channel.") + 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 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/update/channel.go b/internal/update/channel.go index a92ba4ba..56f328e3 100644 --- a/internal/update/channel.go +++ b/internal/update/channel.go @@ -2,61 +2,73 @@ package update import ( "encoding/json" + "fmt" "os" "path/filepath" ) -// ChannelConfig stores the user's update channel preference -type ChannelConfig struct { - Channel string `json:"channel"` // "stable" or "beta" -} +// Channel represents the update channel preference +type Channel string -// DefaultChannel is the default update channel -const DefaultChannel = "stable" +const ( + ChannelStable Channel = "stable" + ChannelBeta Channel = "beta" +) -// ConfigFileName is the name of the channel config file -const ConfigFileName = "update-channel.json" +// ChannelConfig represents the update channel configuration +type ChannelConfig struct { + Channel Channel `json:"channel"` +} // GetConfigPath returns the path to the channel config file func GetConfigPath() (string, error) { - configDir, err := os.UserConfigDir() + homeDir, err := os.UserHomeDir() if err != nil { - return "", err + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + configDir := filepath.Join(homeDir, ".config", "routatic-proxy") + if err := os.MkdirAll(configDir, 0755); err != nil { + return "", fmt.Errorf("failed to create config directory: %w", err) } - return filepath.Join(configDir, "routatic-proxy", ConfigFileName), nil + + return filepath.Join(configDir, "update-channel.json"), nil } -// GetChannel reads the user's preferred update channel -func GetChannel() (string, error) { +// GetChannel returns the user's preferred update channel +func GetChannel() (Channel, error) { configPath, err := GetConfigPath() if err != nil { - return DefaultChannel, nil // Fall back to default if we can't determine path + return ChannelStable, err + } + + // If config file doesn't exist, default to stable + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return ChannelStable, nil } data, err := os.ReadFile(configPath) if err != nil { - if os.IsNotExist(err) { - return DefaultChannel, nil // No preference set, use default - } - return "", err + return ChannelStable, fmt.Errorf("failed to read channel config: %w", err) } var config ChannelConfig if err := json.Unmarshal(data, &config); err != nil { - return DefaultChannel, nil // Corrupted config, use default + return ChannelStable, fmt.Errorf("failed to parse channel config: %w", err) } - if config.Channel != "stable" && config.Channel != "beta" { - return DefaultChannel, nil // Invalid channel, use default + // Validate channel + if config.Channel != ChannelStable && config.Channel != ChannelBeta { + return ChannelStable, nil } return config.Channel, nil } // SetChannel saves the user's preferred update channel -func SetChannel(channel string) error { - if channel != "stable" && channel != "beta" { - return nil // Silently ignore invalid channels +func SetChannel(channel Channel) error { + if channel != ChannelStable && channel != ChannelBeta { + return fmt.Errorf("invalid channel: %s (must be 'stable' or 'beta')", channel) } configPath, err := GetConfigPath() @@ -64,17 +76,15 @@ func SetChannel(channel string) error { return err } - // Ensure directory exists - dir := filepath.Dir(configPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - config := ChannelConfig{Channel: channel} data, err := json.MarshalIndent(config, "", " ") if err != nil { - return err + return fmt.Errorf("failed to marshal channel config: %w", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("failed to write channel config: %w", err) } - return os.WriteFile(configPath, data, 0644) + return nil } From fe442e78c00abceea17866c97f107f5b9e38fc3a Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 17:34:51 +0200 Subject: [PATCH 14/59] feat: Update entry point to start in headless mode and adjust autostart configurations --- Dockerfile | 2 +- cmd/routatic-proxy/main.go | 2 +- cmd/routatic-proxy/update.go | 2 +- internal/daemon/autostart_darwin.go | 2 +- internal/daemon/autostart_linux.go | 2 +- internal/daemon/autostart_windows.go | 2 +- internal/update/channel.go | 62 +++++++++++++--------------- 7 files changed, 35 insertions(+), 39 deletions(-) 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/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index fef7c1be..b8e9a1c7 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -242,7 +242,7 @@ Press Ctrl+C to stop both servers.`, opts := daemon.BackgroundOpts{ ConfigPath: configPath, Port: port, - Command: "start", + Command: "start", } return daemon.ForkIntoBackground(opts) } diff --git a/cmd/routatic-proxy/update.go b/cmd/routatic-proxy/update.go index b0fd2fe6..bc94fa31 100644 --- a/cmd/routatic-proxy/update.go +++ b/cmd/routatic-proxy/update.go @@ -32,7 +32,7 @@ Examples: currentVersion = "dev" } - release, err := update.GetLatestRelease(channel) + release, err := update.GetLatestRelease(string(channel)) if err != nil { return fmt.Errorf("failed to check for updates: %w", err) } 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/update/channel.go b/internal/update/channel.go index 56f328e3..ebf4a4c1 100644 --- a/internal/update/channel.go +++ b/internal/update/channel.go @@ -7,19 +7,17 @@ import ( "path/filepath" ) -// Channel represents the update channel preference -type Channel string - -const ( - ChannelStable Channel = "stable" - ChannelBeta Channel = "beta" -) - -// ChannelConfig represents the update channel configuration +// ChannelConfig stores the user's preferred update channel type ChannelConfig struct { - Channel Channel `json:"channel"` + Channel string `json:"channel"` // "stable" or "beta" } +// DefaultChannel is the default update channel +const DefaultChannel = "stable" + +// ConfigFileName is the name of the channel config file +const ConfigFileName = "update-channel.json" + // GetConfigPath returns the path to the channel config file func GetConfigPath() (string, error) { homeDir, err := os.UserHomeDir() @@ -32,43 +30,41 @@ func GetConfigPath() (string, error) { return "", fmt.Errorf("failed to create config directory: %w", err) } - return filepath.Join(configDir, "update-channel.json"), nil + return filepath.Join(configDir, ConfigFileName), nil } -// GetChannel returns the user's preferred update channel -func GetChannel() (Channel, error) { +// GetChannel reads the user's preferred channel from config +// Returns "stable" by default if no config exists +func GetChannel() (string, error) { configPath, err := GetConfigPath() if err != nil { - return ChannelStable, err - } - - // If config file doesn't exist, default to stable - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return ChannelStable, nil + return DefaultChannel, err } data, err := os.ReadFile(configPath) if err != nil { - return ChannelStable, fmt.Errorf("failed to read channel config: %w", err) + if os.IsNotExist(err) { + return DefaultChannel, nil + } + return DefaultChannel, fmt.Errorf("failed to read channel config: %w", err) } - var config ChannelConfig - if err := json.Unmarshal(data, &config); err != nil { - return ChannelStable, fmt.Errorf("failed to parse channel config: %w", err) + var cfg ChannelConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return DefaultChannel, fmt.Errorf("failed to parse channel config: %w", err) } - // Validate channel - if config.Channel != ChannelStable && config.Channel != ChannelBeta { - return ChannelStable, nil + if cfg.Channel == "" { + return DefaultChannel, nil } - return config.Channel, nil + return cfg.Channel, nil } -// SetChannel saves the user's preferred update channel -func SetChannel(channel Channel) error { - if channel != ChannelStable && channel != ChannelBeta { - return fmt.Errorf("invalid channel: %s (must be 'stable' or 'beta')", channel) +// SetChannel saves the user's preferred channel to config +func SetChannel(channel string) error { + if channel != "stable" && channel != "beta" { + return fmt.Errorf("invalid channel %q: must be 'stable' or 'beta'", channel) } configPath, err := GetConfigPath() @@ -76,8 +72,8 @@ func SetChannel(channel Channel) error { return err } - config := ChannelConfig{Channel: channel} - data, err := json.MarshalIndent(config, "", " ") + cfg := ChannelConfig{Channel: channel} + data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return fmt.Errorf("failed to marshal channel config: %w", err) } From 242157051dfaca3741bca271ac8e68397e8d9e05 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 17:36:30 +0200 Subject: [PATCH 15/59] feat: Improve update channel command documentation and error handling --- cmd/routatic-proxy/update_channel.go | 19 ++++---- internal/update/channel.go | 71 +++++++++++++++------------- 2 files changed, 46 insertions(+), 44 deletions(-) diff --git a/cmd/routatic-proxy/update_channel.go b/cmd/routatic-proxy/update_channel.go index b5212ef0..78cb3a76 100644 --- a/cmd/routatic-proxy/update_channel.go +++ b/cmd/routatic-proxy/update_channel.go @@ -10,10 +10,10 @@ import ( var updateChannelCmd = &cobra.Command{ Use: "update-channel [stable|beta]", Short: "Switch between stable and beta update channels", - Long: `Switch between stable (production) and beta (early access) update channels. + Long: `Switch between update channels: -By default, updates come from the stable channel. Use this command to opt into -beta releases for early access to new features. + 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 @@ -25,26 +25,25 @@ Examples: // Show current channel channel, err := update.GetChannel() if err != nil { - return fmt.Errorf("failed to get update channel: %w", err) + 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 beta # Early access releases") - fmt.Println(" routatic-proxy update-channel stable # Production releases (default)") + 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 fmt.Errorf("failed to set update channel: %w", err) + return err } if channel == update.ChannelBeta { - fmt.Println("Switched to beta update channel.") - fmt.Println("You will now receive beta (early access) releases when running 'routatic-proxy update'.") + 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("Switched to stable update channel.") 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") } diff --git a/internal/update/channel.go b/internal/update/channel.go index ebf4a4c1..b7f2292d 100644 --- a/internal/update/channel.go +++ b/internal/update/channel.go @@ -7,73 +7,76 @@ import ( "path/filepath" ) +// Channel represents an update channel +type Channel string + +const ( + ChannelStable Channel = "stable" + ChannelBeta Channel = "beta" +) + // ChannelConfig stores the user's preferred update channel type ChannelConfig struct { - Channel string `json:"channel"` // "stable" or "beta" + Channel Channel `json:"channel"` } -// DefaultChannel is the default update channel -const DefaultChannel = "stable" - -// ConfigFileName is the name of the channel config file -const ConfigFileName = "update-channel.json" - -// GetConfigPath returns the path to the channel config file -func GetConfigPath() (string, error) { - homeDir, err := os.UserHomeDir() +// getChannelConfigPath returns the path to the channel config file +func getChannelConfigPath() (string, error) { + configDir, err := os.UserConfigDir() if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) + return "", fmt.Errorf("failed to get config directory: %w", err) } - configDir := filepath.Join(homeDir, ".config", "routatic-proxy") - if err := os.MkdirAll(configDir, 0755); err != nil { + appConfigDir := filepath.Join(configDir, "routatic-proxy") + if err := os.MkdirAll(appConfigDir, 0755); err != nil { return "", fmt.Errorf("failed to create config directory: %w", err) } - return filepath.Join(configDir, ConfigFileName), nil + return filepath.Join(appConfigDir, "update-channel.json"), nil } -// GetChannel reads the user's preferred channel from config -// Returns "stable" by default if no config exists -func GetChannel() (string, error) { - configPath, err := GetConfigPath() +// GetChannel returns the user's preferred update channel, defaulting to stable +func GetChannel() (Channel, error) { + configPath, err := getChannelConfigPath() if err != nil { - return DefaultChannel, err + return ChannelStable, err } data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { - return DefaultChannel, nil + // No config file yet, return default + return ChannelStable, nil } - return DefaultChannel, fmt.Errorf("failed to read channel config: %w", err) + return ChannelStable, fmt.Errorf("failed to read channel config: %w", err) } - var cfg ChannelConfig - if err := json.Unmarshal(data, &cfg); err != nil { - return DefaultChannel, fmt.Errorf("failed to parse channel config: %w", err) + var config ChannelConfig + if err := json.Unmarshal(data, &config); err != nil { + return ChannelStable, fmt.Errorf("failed to parse channel config: %w", err) } - if cfg.Channel == "" { - return DefaultChannel, nil + // Validate channel + if config.Channel != ChannelStable && config.Channel != ChannelBeta { + return ChannelStable, nil } - return cfg.Channel, nil + return config.Channel, nil } -// SetChannel saves the user's preferred channel to config -func SetChannel(channel string) error { - if channel != "stable" && channel != "beta" { - return fmt.Errorf("invalid channel %q: must be 'stable' or 'beta'", channel) +// SetChannel saves the user's preferred update channel +func SetChannel(channel Channel) error { + if channel != ChannelStable && channel != ChannelBeta { + return fmt.Errorf("invalid channel: %s (must be 'stable' or 'beta')", channel) } - configPath, err := GetConfigPath() + configPath, err := getChannelConfigPath() if err != nil { return err } - cfg := ChannelConfig{Channel: channel} - data, err := json.MarshalIndent(cfg, "", " ") + config := ChannelConfig{Channel: channel} + data, err := json.MarshalIndent(config, "", " ") if err != nil { return fmt.Errorf("failed to marshal channel config: %w", err) } From 44641f40f67b781736883974fba93519fd5a7cbe Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 17:42:56 +0200 Subject: [PATCH 16/59] feat: Clean up import statements and adjust struct field formatting in analytics --- internal/server/server.go | 2 +- internal/storage/analytics.go | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 59c4294c..dd9ad765 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -16,6 +16,7 @@ import ( "github.com/routatic/proxy/internal/config" "github.com/routatic/proxy/internal/core" "github.com/routatic/proxy/internal/debug" + "github.com/routatic/proxy/internal/gui" "github.com/routatic/proxy/internal/handlers" "github.com/routatic/proxy/internal/history" "github.com/routatic/proxy/internal/metrics" @@ -24,7 +25,6 @@ import ( "github.com/routatic/proxy/internal/status" "github.com/routatic/proxy/internal/storage" "github.com/routatic/proxy/internal/token" - "github.com/routatic/proxy/internal/gui" ) // Server represents the proxy server. diff --git a/internal/storage/analytics.go b/internal/storage/analytics.go index 3b7ce135..3b3de147 100644 --- a/internal/storage/analytics.go +++ b/internal/storage/analytics.go @@ -134,12 +134,12 @@ func (a *Analytics) GetModelBreakdown(days int) ([]ModelBreakdown, error) { // ProviderBreakdown holds per-provider aggregates. type ProviderBreakdown struct { - Provider string `json:"provider"` - Requests int64 `json:"requests"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - FallbackRate float64 `json:"fallback_rate"` // % of requests that were fallbacks - EstCostUSD float64 `json:"est_cost_usd"` + Provider string `json:"provider"` + Requests int64 `json:"requests"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + FallbackRate float64 `json:"fallback_rate"` // % of requests that were fallbacks + EstCostUSD float64 `json:"est_cost_usd"` } // GetProviderBreakdown returns usage by provider (with fallback rate). From 4161b647c4ec910cf93d31d2fd44e3fd5d742b80 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 17:50:18 +0200 Subject: [PATCH 17/59] fix: lint errors (errcheck, unused) --- cmd/routatic-proxy/main.go | 1 + internal/storage/analytics.go | 6 +++--- internal/update/update.go | 16 ++++++++-------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index b8e9a1c7..66e4ce2f 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -59,6 +59,7 @@ Legacy ~/.config/oc-go-cc/config.json and OC_GO_CC_* environment variables are s rootCmd.AddCommand(autostartCmd()) rootCmd.AddCommand(updateCmd) rootCmd.AddCommand(startCmd()) + rootCmd.AddCommand(updateChannelCmd) if err := rootCmd.Execute(); err != nil { os.Exit(1) diff --git a/internal/storage/analytics.go b/internal/storage/analytics.go index 3b3de147..c5c774c6 100644 --- a/internal/storage/analytics.go +++ b/internal/storage/analytics.go @@ -110,7 +110,7 @@ func (a *Analytics) GetModelBreakdown(days int) ([]ModelBreakdown, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() var result []ModelBreakdown for rows.Next() { @@ -172,7 +172,7 @@ func (a *Analytics) GetProviderBreakdown(days int) ([]ProviderBreakdown, error) if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() var result []ProviderBreakdown for rows.Next() { @@ -217,7 +217,7 @@ func (a *Analytics) GetDailyTokenTrend(days int) ([]DailyTokenPoint, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { _ = rows.Close() }() var result []DailyTokenPoint for rows.Next() { diff --git a/internal/update/update.go b/internal/update/update.go index 30ef120d..74207a1c 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -38,7 +38,7 @@ func GetLatestRelease(channel string) (*GitHubRelease, error) { if err != nil { return nil, fmt.Errorf("failed to fetch releases: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) @@ -110,7 +110,7 @@ func DownloadAndInstall(url, filename string) error { if err != nil { return fmt.Errorf("failed to download: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download failed with status %d", resp.StatusCode) @@ -122,14 +122,14 @@ func DownloadAndInstall(url, filename string) error { return fmt.Errorf("failed to create temp file: %w", err) } tmpPath := tmpFile.Name() - defer os.Remove(tmpPath) + defer func() { _ = os.Remove(tmpPath) }() // Copy downloaded content to temp file if _, err := io.Copy(tmpFile, resp.Body); err != nil { - tmpFile.Close() + _ = tmpFile.Close() return fmt.Errorf("failed to write temp file: %w", err) } - tmpFile.Close() + _ = tmpFile.Close() // Make executable on Unix if runtime.GOOS != "windows" { @@ -149,7 +149,7 @@ func DownloadAndInstall(url, filename string) error { if runtime.GOOS == "windows" { oldPath := execPath + ".old" // Remove any previous .old file - os.Remove(oldPath) + _ = os.Remove(oldPath) // Rename current executable if err := os.Rename(execPath, oldPath); err != nil { return fmt.Errorf("failed to rename current executable: %w", err) @@ -157,11 +157,11 @@ func DownloadAndInstall(url, filename string) error { // Move new executable into place if err := os.Rename(tmpPath, execPath); err != nil { // Try to restore old executable - os.Rename(oldPath, execPath) + _ = os.Rename(oldPath, execPath) return fmt.Errorf("failed to install new executable: %w", err) } // Clean up old executable - os.Remove(oldPath) + _ = os.Remove(oldPath) } else { // On Unix, we can directly replace if err := os.Rename(tmpPath, execPath); err != nil { From 33fffe3b37fbc9aa1671d8a6a98057842aff5302 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 17:52:14 +0200 Subject: [PATCH 18/59] fix: remaining errcheck --- internal/gui/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gui/server.go b/internal/gui/server.go index 9510e94f..820725c1 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -600,7 +600,7 @@ func (s *Server) ensurePortAvailable(startPort int) error { } continue } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() var m struct { ProxyRunning bool `json:"proxy_running"` From ece171fc9c5d0cac28d4d43021d1dbc064651f06 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 17:58:06 +0200 Subject: [PATCH 19/59] feat: Enhance build info with version, commit, and date extraction; add tests for build info functions --- internal/buildinfo/buildinfo.go | 44 +++++++++++++++++++++------- internal/buildinfo/buildinfo_test.go | 39 ++++++++++++++++++++++++ scripts/git-hooks/pre-push | 2 +- 3 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 internal/buildinfo/buildinfo_test.go diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index f83074ad..56a3c5e3 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -1,18 +1,42 @@ package buildinfo -import "os" +import "runtime/debug" -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. Set via ldflags: -X github.com/routatic/proxy/internal/buildinfo.Date=YYYY-MM-DDTHH:MM:SSZ + Date = "unknown" +) -func BinaryPath() string { - path, err := os.Executable() - if err != nil { - return "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 Date == "unknown" && s.Value != "" { + Date = s.Value + } + } + } } - return path } -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..8d1951cc --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,39 @@ +package buildinfo + +import ( + "strings" + "testing" +) + +func TestVersionDefaults(t *testing.T) { + if Version == "" { + t.Error("Version should not be empty") + } + if Commit == "" { + t.Error("Commit should not be empty") + } + if Date == "" { + t.Error("Date should not be empty") + } +} + +func TestString(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() runs automatically; ensure calling it again (via re-assignment path) is safe. + // We just verify that after package init, values are non-empty. + if Version == "" || Commit == "" || Date == "" { + t.Fatal("expected non-empty build info after init") + } +} diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push index ff7e78c0..11f50e6f 100755 --- a/scripts/git-hooks/pre-push +++ b/scripts/git-hooks/pre-push @@ -71,7 +71,7 @@ echo "" # Check 4: Tests echo -e "${YELLOW}[4/5] Running tests...${NC}" -go test ./internal/... ./pkg/... ./cmd/... -v -race 2>&1 | while IFS= read -r line; do +go test ./... -v -race 2>&1 | while IFS= read -r line; do # Suppress verbose test output, show summary if [[ "$line" =~ ^(PASS|FAIL|===\s+RUN|---\s+(PASS|FAIL)|coverage:) ]]; then echo " $line" From fda9ea25361a4e1e721000d17e0f5d0aba6881a6 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 18:00:00 +0200 Subject: [PATCH 20/59] refactor: Simplify test cases for build info; improve error messages and assertions --- Makefile | 2 +- internal/buildinfo/buildinfo_test.go | 55 ++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index d9564bcd..d71dc43f 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,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/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go index 8d1951cc..fb35b3c9 100644 --- a/internal/buildinfo/buildinfo_test.go +++ b/internal/buildinfo/buildinfo_test.go @@ -1,23 +1,55 @@ package buildinfo import ( + "os" + "strconv" "strings" "testing" ) -func TestVersionDefaults(t *testing.T) { +func TestVersionNotEmpty(t *testing.T) { if Version == "" { - t.Error("Version should not be empty") + t.Error("Version must not be empty") } - if Commit == "" { - t.Error("Commit should not be empty") +} + +func TestBuildTimeNotEmpty(t *testing.T) { + if BuildTime == "" { + t.Error("BuildTime must not be empty") } +} + +func TestDateAlias(t *testing.T) { + // Date should be populated (either from ldflags or from init via BuildTime) if Date == "" { - t.Error("Date should not be empty") + 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 TestString(t *testing.T) { +func TestStringContainsAllFields(t *testing.T) { s := String() if !strings.Contains(s, Version) { t.Errorf("String() should contain Version; got %q", s) @@ -25,15 +57,14 @@ func TestString(t *testing.T) { 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) + if !strings.Contains(s, BuildTime) { + t.Errorf("String() should contain BuildTime; got %q", s) } } func TestInitDoesNotPanic(t *testing.T) { - // init() runs automatically; ensure calling it again (via re-assignment path) is safe. - // We just verify that after package init, values are non-empty. - if Version == "" || Commit == "" || Date == "" { - t.Fatal("expected non-empty build info after init") + // init() has already run; just ensure the package level vars are sane. + if Version == "" || Commit == "" || BuildTime == "" { + t.Fatal("expected non-empty build info after package init") } } From 3210302be2ba2f80f0c010508d20cd025a755af2 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 18:03:52 +0200 Subject: [PATCH 21/59] fix: add missing buildinfo functions --- internal/buildinfo/buildinfo.go | 24 +++++++++++++++++++++++- internal/handlers/health.go | 2 +- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index 56a3c5e3..4789b066 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -1,6 +1,10 @@ package buildinfo -import "runtime/debug" +import ( + "os" + "path/filepath" + "runtime/debug" +) var ( // Version is the semantic version. Set via ldflags: -X github.com/routatic/proxy/internal/buildinfo.Version=vX.Y.Z @@ -36,6 +40,24 @@ func init() { } } +// PID returns the current process ID. +func PID() int { + return os.Getpid() +} + +// BinaryPath returns the absolute path to the running binary. +func BinaryPath() string { + execPath, err := os.Executable() + if err != nil { + return "unknown" + } + absPath, err := filepath.Abs(execPath) + if err != nil { + return execPath + } + return absPath +} + // String returns a human-readable build info summary. func String() string { return Version + " (" + Commit + ") built at " + Date diff --git a/internal/handlers/health.go b/internal/handlers/health.go index 0af25b55..27ae9303 100644 --- a/internal/handlers/health.go +++ b/internal/handlers/health.go @@ -45,7 +45,7 @@ func (h *HealthHandler) HandleHealth(w http.ResponseWriter, r *http.Request) { "status": "ok", "service": "routatic-proxy", "version": buildinfo.Version, - "build_time": buildinfo.BuildTime, + "build_time": buildinfo.Date, "pid": buildinfo.PID(), "binary": buildinfo.BinaryPath(), "metrics": map[string]interface{}{ From 76f5985e4de99067cf3b5e2f8b53a8502fe8df94 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 18:06:30 +0200 Subject: [PATCH 22/59] fix: buildinfo tests and PIDString --- internal/buildinfo/buildinfo.go | 17 ++++++++++++++--- internal/buildinfo/buildinfo_test.go | 15 ++++----------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index 4789b066..04c76ede 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime/debug" + "strconv" ) var ( @@ -11,8 +12,10 @@ var ( 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. Set via ldflags: -X github.com/routatic/proxy/internal/buildinfo.Date=YYYY-MM-DDTHH:MM:SSZ + // Date is the build timestamp (alias for BuildTime for compatibility). Date = "unknown" + // BuildTime is the build timestamp. Set via ldflags. + BuildTime = "unknown" ) func init() { @@ -32,8 +35,11 @@ func init() { Commit = s.Value } case "vcs.time": - if Date == "unknown" && s.Value != "" { - Date = s.Value + if BuildTime == "unknown" && s.Value != "" { + BuildTime = s.Value + if Date == "unknown" { + Date = s.Value + } } } } @@ -45,6 +51,11 @@ 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 { execPath, err := os.Executable() diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go index fb35b3c9..ae664546 100644 --- a/internal/buildinfo/buildinfo_test.go +++ b/internal/buildinfo/buildinfo_test.go @@ -13,14 +13,7 @@ func TestVersionNotEmpty(t *testing.T) { } } -func TestBuildTimeNotEmpty(t *testing.T) { - if BuildTime == "" { - t.Error("BuildTime must not be empty") - } -} - -func TestDateAlias(t *testing.T) { - // Date should be populated (either from ldflags or from init via BuildTime) +func TestDateNotEmpty(t *testing.T) { if Date == "" { t.Error("Date must not be empty") } @@ -57,14 +50,14 @@ func TestStringContainsAllFields(t *testing.T) { if !strings.Contains(s, Commit) { t.Errorf("String() should contain Commit; got %q", s) } - if !strings.Contains(s, BuildTime) { - t.Errorf("String() should contain BuildTime; 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 == "" || BuildTime == "" { + if Version == "" || Commit == "" || Date == "" { t.Fatal("expected non-empty build info after package init") } } From cd3fa8f382b209fd489c20ad109220c87619a50c Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 18:45:37 +0200 Subject: [PATCH 23/59] feat: macOS native GUI for start command, Linux/Windows print URL --- cmd/routatic-proxy/main.go | 10 ++++-- cmd/routatic-proxy/start_gui_darwin.go | 37 ++++++++++++++++++++ cmd/routatic-proxy/start_gui_darwin_nocgo.go | 13 +++++++ cmd/routatic-proxy/start_gui_other.go | 13 +++++++ 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 cmd/routatic-proxy/start_gui_darwin.go create mode 100644 cmd/routatic-proxy/start_gui_darwin_nocgo.go create mode 100644 cmd/routatic-proxy/start_gui_other.go diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 66e4ce2f..607d5a20 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -380,9 +380,13 @@ Press Ctrl+C to stop both servers.`, } else { fmt.Println(" export ANTHROPIC_AUTH_TOKEN=unused") } - fmt.Println() - fmt.Printf("Dashboard: %s\n", guiURL) - fmt.Println("\nPress Ctrl+C to stop.") + + // 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.") + } // Wait for signal. sigCh := make(chan os.Signal, 1) diff --git a/cmd/routatic-proxy/start_gui_darwin.go b/cmd/routatic-proxy/start_gui_darwin.go new file mode 100644 index 00000000..e6293cd4 --- /dev/null +++ b/cmd/routatic-proxy/start_gui_darwin.go @@ -0,0 +1,37 @@ +//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...") + + wv := webview.New(false) + wv.SetTitle("routatic-proxy") + wv.SetSize(1200, 800, webview.HintNone) + wv.Navigate(guiURL) + + // Set up system tray + systray.Run(func() { + systray.SetTitle("routatic-proxy") + systray.SetTooltip("routatic-proxy is running") + mQuit := systray.AddMenuItem("Quit", "Stop the proxy") + go func() { + <-mQuit.ClickedCh + wv.Dispatch(func() { + wv.Terminate() + }) + }() + }, nil) + + wv.Run() + wv.Destroy() + 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 +} From 33e63919e06cebd75372542868b92614a0d9f0a8 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 18:48:23 +0200 Subject: [PATCH 24/59] fix: Docker uses serve (no GUI) instead of invalid --headless flag --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9cc16120..535b98a7 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", "start", "--headless"] +ENTRYPOINT ["routatic-proxy", "serve"] From 616dab2086f037fc0201aa3ac2599ad3c3d01566 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 18:49:59 +0200 Subject: [PATCH 25/59] fix: Docker uses start --background for unified headless mode --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 535b98a7..56b51d60 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", "--background"] From 6a35c3b627b7176fc3ed261c801d777c795cc348 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 18:54:32 +0200 Subject: [PATCH 26/59] feat: implement --headless flag for start command --- Dockerfile | 2 +- cmd/routatic-proxy/main.go | 46 +++++++++++++++++++++----------------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/Dockerfile b/Dockerfile index 56b51d60..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", "start", "--background"] +ENTRYPOINT ["routatic-proxy", "start", "--headless"] diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 607d5a20..de6089a8 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -353,22 +353,6 @@ Press Ctrl+C to stop both servers.`, } }() - // 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) - } - // Print startup info. fmt.Printf("Starting %s v%s\n", appName, version) fmt.Printf("Proxy listening on %s:%d\n", cfg.Host, cfg.Port) @@ -381,11 +365,31 @@ Press Ctrl+C to stop both servers.`, fmt.Println(" export ANTHROPIC_AUTH_TOKEN=unused") } - // 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.") + 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. From 0adf498c053c26cea0c16ece0849a3509809dc2a Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 18:58:54 +0200 Subject: [PATCH 27/59] fix: correct syntax for --headless implementation --- cmd/routatic-proxy/main.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index de6089a8..87160fd3 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -225,6 +225,7 @@ 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{ @@ -345,6 +346,8 @@ Press Ctrl+C to stop both servers.`, 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 { @@ -367,7 +370,7 @@ Press Ctrl+C to stop both servers.`, if !headless { // Start GUI server on port 3445. - guiSrv := gui.New(gui.Options{ + guiSrv = gui.New(gui.Options{ History: srv.History, Metrics: srv.Metrics(), AtomicConfig: atomicCfg, From 7e4a2ba61ee2441879993d956402d17b74c5367d Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 19:03:45 +0200 Subject: [PATCH 28/59] fix: update start command description to clarify optional dashboard usage --- cmd/routatic-proxy/main.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 87160fd3..73133633 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -230,14 +230,15 @@ func startCmd() *cobra.Command { cmd := &cobra.Command{ Use: "start", - Short: "Start the proxy server with dashboard", - Long: `Start the proxy server and GUI dashboard together. + 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), and the dashboard -is available at http://127.0.0.1:3445. All usage data is persisted to -SQLite regardless of whether the dashboard is open. +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 both servers.`, +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 { From 7780c7d64afc77f134bf820045a94366ea8e7a92 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 19:18:18 +0200 Subject: [PATCH 29/59] fix: update release workflow to use fetch-depth 0 for full history fix: add golang.org/x/mod v0.38.0 dependency for semantic versioning --- .github/workflows/release.yml | 3 ++- go.mod | 1 + go.sum | 2 ++ internal/update/update.go | 8 +++++--- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8115161c..6d105a4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -244,7 +244,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - uses: docker/setup-qemu-action@v3 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/update/update.go b/internal/update/update.go index 74207a1c..46d8bf2b 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -9,6 +9,9 @@ import ( "runtime" "strings" "time" + + "github.com/routatic/proxy/internal/buildinfo" + "golang.org/x/mod/semver" ) // GitHubRelease represents a GitHub release @@ -179,9 +182,8 @@ func CheckForUpdate(currentVersion string, channel string) (*GitHubRelease, erro return nil, err } - // Compare versions (simple string comparison for now) - // In production, you'd want proper semantic version comparison - if release.TagName != currentVersion && release.TagName > currentVersion { + // Compare versions using semantic versioning + if IsNewerVersion(currentVersion, release.TagName) { return release, nil } From e5b211378b91dd34f13e28ce176e1f53332c158a Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 19:19:25 +0200 Subject: [PATCH 30/59] fix: add IsNewerVersion function for semantic version comparison --- internal/update/update.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/update/update.go b/internal/update/update.go index 46d8bf2b..9b8d7842 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/routatic/proxy/internal/buildinfo" "golang.org/x/mod/semver" ) @@ -175,6 +174,16 @@ func DownloadAndInstall(url, filename string) error { return nil } +// IsNewerVersion reports whether candidate is a newer version than current. +// Uses semantic version comparison via golang.org/x/mod/semver. +func IsNewerVersion(current, candidate string) bool { + if semver.IsValid(candidate) && semver.IsValid(current) { + return semver.Compare(candidate, current) > 0 + } + // Fallback for dev versions or non-semver strings + return candidate != current && candidate > current +} + // CheckForUpdate checks if a newer version is available func CheckForUpdate(currentVersion string, channel string) (*GitHubRelease, error) { release, err := GetLatestRelease(channel) From 1b9dfe54576f3b9419dade79245e6a7502169e30 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 19:21:04 +0200 Subject: [PATCH 31/59] fix: remove unnecessary 'with' block from checkout step in release workflow --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d105a4e..d31799f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -244,7 +244,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: fetch-depth: 0 - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 From 1d49ae9eb4eee57cd08dbe783813f978678c85d8 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 19:21:14 +0200 Subject: [PATCH 32/59] fix: add 'with' block to checkout step for fetch-depth configuration --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d31799f7..6d105a4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -244,6 +244,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: fetch-depth: 0 - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 From 31f7581cd016e1e07129c04e8dff2e510ef4cacb Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 19:21:50 +0200 Subject: [PATCH 33/59] fix: refactor GUI port handling to use instance variable --- internal/gui/server.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/gui/server.go b/internal/gui/server.go index 820725c1..d660e700 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -48,6 +48,7 @@ type Server struct { proxyRunning atomic.Bool connectedExisting atomic.Bool proxyPort int + guiPort int startProxy func() error stopProxy func() error catalogDir string @@ -139,14 +140,13 @@ func (s *Server) getProxyPort() int { return s.proxyPort } -var guiPort = 3445 - // Start starts the embedded HTTP server on port 3445 and returns // the URL that the webview should load. If another routatic-proxy instance // is using that port, it is killed before binding. func (s *Server) Start(ctx context.Context) (string, error) { + s.guiPort = 3445 // Ensure port 3445 is free, killing any existing routatic-proxy GUI. - if err := s.ensurePortAvailable(guiPort); err != nil { + if err := s.ensurePortAvailable(); err != nil { return "", fmt.Errorf("gui port check: %w", err) } @@ -185,7 +185,7 @@ func (s *Server) Start(ctx context.Context) (string, error) { mux.HandleFunc("/api/analytics/latency", ah.LatencyStats) } - ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", guiPort)) + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", s.guiPort)) if err != nil { return "", fmt.Errorf("gui server listen: %w", err) } @@ -579,15 +579,16 @@ func writeJSON(w http.ResponseWriter, v any) { // ensurePortAvailable finds an available port for the GUI. // If 3445 is free, uses it. If used by another routatic-proxy, kills it. // If used by a different app, increments port (up to 3454) and notifies user. -func (s *Server) ensurePortAvailable(startPort int) error { +func (s *Server) ensurePortAvailable() error { client := &http.Client{Timeout: 2 * time.Second} + startPort := s.guiPort for p := startPort; p < startPort+10; p++ { // Try to bind ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) if err == nil { _ = ln.Close() - guiPort = p + s.guiPort = p return nil } @@ -615,7 +616,7 @@ func (s *Server) ensurePortAvailable(startPort int) error { ln2, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) if err == nil { _ = ln2.Close() - guiPort = p + s.guiPort = p return nil } // Still blocked, continue to next port From c54b86124c89a62c388e8760739488bbaaff5b89 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 19:51:02 +0200 Subject: [PATCH 34/59] fix: update GUI handling to ensure proper initialization and use atomic types for guiPort --- cmd/routatic-proxy/start_gui_darwin.go | 19 ++++++++------- internal/gui/server.go | 32 +++++++++++++++++--------- internal/handlers/messages.go | 3 +++ 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/cmd/routatic-proxy/start_gui_darwin.go b/cmd/routatic-proxy/start_gui_darwin.go index e6293cd4..112293b3 100644 --- a/cmd/routatic-proxy/start_gui_darwin.go +++ b/cmd/routatic-proxy/start_gui_darwin.go @@ -13,25 +13,28 @@ func openGUI(guiURL string) error { fmt.Printf("\nDashboard: %s\n", guiURL) fmt.Println("Opening native window...") - wv := webview.New(false) - wv.SetTitle("routatic-proxy") - wv.SetSize(1200, 800, webview.HintNone) - wv.Navigate(guiURL) - - // Set up system tray + // 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() }) }() + + wv.Run() + wv.Destroy() }, nil) - wv.Run() - wv.Destroy() return nil } diff --git a/internal/gui/server.go b/internal/gui/server.go index d660e700..a0637dda 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -48,7 +48,7 @@ type Server struct { proxyRunning atomic.Bool connectedExisting atomic.Bool proxyPort int - guiPort int + guiPort atomic.Int32 startProxy func() error stopProxy func() error catalogDir string @@ -144,7 +144,7 @@ func (s *Server) getProxyPort() int { // the URL that the webview should load. If another routatic-proxy instance // is using that port, it is killed before binding. func (s *Server) Start(ctx context.Context) (string, error) { - s.guiPort = 3445 + s.guiPort.Store(3445) // Ensure port 3445 is free, killing any existing routatic-proxy GUI. if err := s.ensurePortAvailable(); err != nil { return "", fmt.Errorf("gui port check: %w", err) @@ -185,7 +185,7 @@ func (s *Server) Start(ctx context.Context) (string, error) { mux.HandleFunc("/api/analytics/latency", ah.LatencyStats) } - ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", s.guiPort)) + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", s.guiPort.Load())) if err != nil { return "", fmt.Errorf("gui server listen: %w", err) } @@ -581,14 +581,14 @@ func writeJSON(w http.ResponseWriter, v any) { // If used by a different app, increments port (up to 3454) and notifies user. func (s *Server) ensurePortAvailable() error { client := &http.Client{Timeout: 2 * time.Second} - startPort := s.guiPort + startPort := int(s.guiPort.Load()) for p := startPort; p < startPort+10; p++ { // Try to bind ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) if err == nil { _ = ln.Close() - s.guiPort = p + s.guiPort.Store(int32(p)) return nil } @@ -616,7 +616,7 @@ func (s *Server) ensurePortAvailable() error { ln2, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) if err == nil { _ = ln2.Close() - s.guiPort = p + s.guiPort.Store(int32(p)) return nil } // Still blocked, continue to next port @@ -654,12 +654,22 @@ func (s *Server) killProcessOnPort(port int) error { } // Verify it's routatic-proxy before killing - cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) - if err != nil { - continue + var isOurProcess bool + if runtime.GOOS == "linux" { + cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + if err == nil { + // cmdline uses null bytes as separators + isOurProcess = strings.Contains(string(cmdline), "routatic-proxy") + } + } else { + // macOS / other Unix: use ps to get process name + output, err := exec.Command("ps", "-p", pidStr, "-o", "comm=").Output() + if err == nil { + name := strings.TrimSpace(string(output)) + isOurProcess = strings.Contains(name, "routatic-proxy") || strings.Contains(name, "proxy") + } } - // cmdline uses null bytes as separators - if strings.Contains(string(cmdline), "routatic-proxy") { + if isOurProcess { s.logger.Info("terminating routatic-proxy process", "pid", pid) p, _ := os.FindProcess(pid) if p != nil { diff --git a/internal/handlers/messages.go b/internal/handlers/messages.go index 5a0749bf..e5326488 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -212,6 +212,9 @@ func isLowValueResponse(scenario router.Scenario, outputTokens int, hasContent b if outputTokens >= 64 { return false } + if hasContent { + return false + } switch scenario { case router.ScenarioLongContext, router.ScenarioComplex: return true From 8b6c8f97987a6984ce8008a16ea9e0517643a180 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 20:09:53 +0200 Subject: [PATCH 35/59] fix: enhance shutdown handling and add headless mode flag --- cmd/routatic-proxy/main.go | 5 +- cmd/routatic-proxy/start_gui_darwin.go | 2 + internal/gui/server.go | 129 +++++++++++++++++-------- 3 files changed, 96 insertions(+), 40 deletions(-) diff --git a/cmd/routatic-proxy/main.go b/cmd/routatic-proxy/main.go index 73133633..7e9d778a 100644 --- a/cmd/routatic-proxy/main.go +++ b/cmd/routatic-proxy/main.go @@ -409,7 +409,9 @@ Press Ctrl+C to stop the server.`, shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutdownCancel() _ = srv.Shutdown(shutdownCtx) - _ = guiSrv.Shutdown(shutdownCtx) + if guiSrv != nil { + _ = guiSrv.Shutdown(shutdownCtx) + } return nil }, @@ -418,6 +420,7 @@ Press Ctrl+C to stop the server.`, 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") diff --git a/cmd/routatic-proxy/start_gui_darwin.go b/cmd/routatic-proxy/start_gui_darwin.go index 112293b3..411d54d7 100644 --- a/cmd/routatic-proxy/start_gui_darwin.go +++ b/cmd/routatic-proxy/start_gui_darwin.go @@ -30,10 +30,12 @@ func openGUI(guiURL string) error { wv.Dispatch(func() { wv.Terminate() }) + systray.Quit() }() wv.Run() wv.Destroy() + systray.Quit() }, nil) return nil diff --git a/internal/gui/server.go b/internal/gui/server.go index a0637dda..2d2c1938 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -633,51 +633,21 @@ func (s *Server) ensurePortAvailable() error { } // killProcessOnPort terminates the process listening on the given port. +// Platform-aware: uses lsof+ps on Unix, netstat+tasklist on Windows. func (s *Server) killProcessOnPort(port int) error { - // Use lsof to find the PID - cmd := exec.Command("lsof", "-t", "-i", fmt.Sprintf(":%d", port)) - output, err := cmd.Output() - if err != nil { - // lsof failed or returned nothing - port might be free now + pids, err := s.findPIDsOnPort(port) + if err != nil || len(pids) == 0 { return nil } - pids := strings.Split(strings.TrimSpace(string(output)), "\n") killed := false - for _, pidStr := range pids { - if pidStr == "" { + for _, pid := range pids { + if !s.isRoutaticProxyProcess(pid) { continue } - pid, err := strconv.Atoi(pidStr) - if err != nil { - continue - } - - // Verify it's routatic-proxy before killing - var isOurProcess bool - if runtime.GOOS == "linux" { - cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) - if err == nil { - // cmdline uses null bytes as separators - isOurProcess = strings.Contains(string(cmdline), "routatic-proxy") - } - } else { - // macOS / other Unix: use ps to get process name - output, err := exec.Command("ps", "-p", pidStr, "-o", "comm=").Output() - if err == nil { - name := strings.TrimSpace(string(output)) - isOurProcess = strings.Contains(name, "routatic-proxy") || strings.Contains(name, "proxy") - } - } - if isOurProcess { - s.logger.Info("terminating routatic-proxy process", "pid", pid) - p, _ := os.FindProcess(pid) - if p != nil { - _ = p.Signal(os.Interrupt) - time.Sleep(500 * time.Millisecond) - _ = p.Kill() - killed = true - } + s.logger.Info("terminating routatic-proxy process", "pid", pid) + if s.killProcess(pid) { + killed = true } } @@ -689,7 +659,6 @@ func (s *Server) killProcessOnPort(port int) error { for i := 0; i < 10; i++ { conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port)) if err != nil { - // Port is now free return nil } _ = conn.Close() @@ -699,6 +668,88 @@ func (s *Server) killProcessOnPort(port int) error { return fmt.Errorf("port %d not released after killing process", port) } +// findPIDsOnPort returns PIDs listening on the given port. +func (s *Server) findPIDsOnPort(port int) ([]int, error) { + switch runtime.GOOS { + case "windows": + cmd := exec.Command("cmd", "/c", fmt.Sprintf("netstat -ano | findstr :%d", port)) + output, err := cmd.Output() + if err != nil { + return nil, err + } + var pids []int + for _, line := range strings.Split(string(output), "\n") { + fields := strings.Fields(line) + if len(fields) >= 5 { + if pid, err := strconv.Atoi(fields[len(fields)-1]); err == nil && pid > 0 { + pids = append(pids, pid) + } + } + } + return pids, nil + default: + cmd := exec.Command("lsof", "-t", "-i", fmt.Sprintf(":%d", port)) + output, err := cmd.Output() + if err != nil { + return nil, err + } + var pids []int + for _, pidStr := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if pidStr == "" { + continue + } + if pid, err := strconv.Atoi(pidStr); err == nil { + pids = append(pids, pid) + } + } + return pids, nil + } +} + +// isRoutaticProxyProcess checks if the PID belongs to routatic-proxy. +func (s *Server) isRoutaticProxyProcess(pid int) bool { + switch runtime.GOOS { + case "windows": + cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/FO", "CSV", "/NH") + output, err := cmd.Output() + if err != nil { + return false + } + return strings.Contains(string(output), "routatic-proxy") + case "linux": + cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + if err != nil { + return false + } + return strings.Contains(string(cmdline), "routatic-proxy") + default: + output, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "comm=").Output() + if err != nil { + return false + } + name := strings.TrimSpace(string(output)) + return strings.Contains(name, "routatic-proxy") || strings.Contains(name, "proxy") + } +} + +// killProcess terminates a process by PID. Returns true if successful. +func (s *Server) killProcess(pid int) bool { + switch runtime.GOOS { + case "windows": + cmd := exec.Command("taskkill", "/F", "/PID", strconv.Itoa(pid)) + return cmd.Run() == nil + default: + p, err := os.FindProcess(pid) + if err != nil { + return false + } + _ = p.Signal(os.Interrupt) + time.Sleep(500 * time.Millisecond) + _ = p.Kill() + return true + } +} + // securityHeadersMiddleware adds security headers to all responses. func securityHeadersMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 6d320b628a10ea2e27478a78c8f2f9c442dce547 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 20:13:11 +0200 Subject: [PATCH 36/59] fix: refine process name check in isRoutaticProxyProcess function --- internal/gui/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gui/server.go b/internal/gui/server.go index 2d2c1938..beb17180 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -728,7 +728,7 @@ func (s *Server) isRoutaticProxyProcess(pid int) bool { return false } name := strings.TrimSpace(string(output)) - return strings.Contains(name, "routatic-proxy") || strings.Contains(name, "proxy") + return strings.Contains(name, "routatic-proxy") } } From 4cf8ac19bf7416f58de0189626d6016a49ff3249 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 20:25:59 +0200 Subject: [PATCH 37/59] Update internal/gui/server.go Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> Signed-off-by: TUYIZERE Samuel --- internal/gui/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gui/server.go b/internal/gui/server.go index beb17180..58f8d757 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -672,7 +672,7 @@ func (s *Server) killProcessOnPort(port int) error { func (s *Server) findPIDsOnPort(port int) ([]int, error) { switch runtime.GOOS { case "windows": - cmd := exec.Command("cmd", "/c", fmt.Sprintf("netstat -ano | findstr :%d", port)) + cmd := exec.Command("cmd", "/c", fmt.Sprintf("netstat -ano | findstr /r /c:\":%d \"", port)) output, err := cmd.Output() if err != nil { return nil, err From 76ab66df8d066a791828d734b9d22b82598faa53 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 21:00:52 +0200 Subject: [PATCH 38/59] fix: update UI messages for better clarity and adjust styles for improved aesthetics --- .gitignore | 1 + internal/gui/assets/app.js | 9 +++++---- internal/gui/assets/index.html | 4 ++-- internal/gui/assets/style.css | 34 +++++++++++++++++++--------------- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index ae5f686d..5dbcb525 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ brag-output-** .claude router.test +.playwright-mcp diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index fa471eed..082f7e55 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -1802,7 +1802,7 @@ const AnalyticsModule = { wrap.innerHTML = ''; if (!items.length) { - wrap.innerHTML = '
No data
'; + wrap.innerHTML = '
No usage data yet
'; return; } @@ -1822,7 +1822,8 @@ const AnalyticsModule = { const col = this.palette[idx % this.palette.length]; segs += `${col} ${off.toFixed(1)}% ${(off + pct).toFixed(1)}%, `; off += pct; - legend.push(`
${this.escapeHtml(it.name||'Unknown')}${v}
`); + const label = it.model || it.provider || it.name || 'Unknown'; + legend.push(`
${this.escapeHtml(label)}${v}
`); }); const html = `
${legend.join('')}
`; @@ -1834,7 +1835,7 @@ const AnalyticsModule = { if (!wrap) return; wrap.innerHTML = ''; if (!points.length) { - wrap.innerHTML = '
No trend data
'; + wrap.innerHTML = '
No trend data yet. Run some requests to see analytics.
'; return; } @@ -1880,7 +1881,7 @@ const AnalyticsModule = { wrap.innerHTML = svg; }, - renderEmpty(msg = 'No data') { + 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}
`; diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index ed1d3fe2..a81ee150 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -37,7 +37,7 @@ - + @@ -581,7 +581,7 @@
- +
diff --git a/internal/gui/assets/style.css b/internal/gui/assets/style.css index 6eb83418..d692e601 100644 --- a/internal/gui/assets/style.css +++ b/internal/gui/assets/style.css @@ -7,8 +7,8 @@ --border: #d8d8dc; --text: #1d1d1f; --text-muted: #6e6e73; - --accent: #0071e3; - --accent-hover: #0077ed; + --accent: #6366f1; + --accent-hover: #4f46e5; --success: #28a745; --error: #dc3545; --warning: #fd7e14; @@ -37,7 +37,7 @@ /* Shadows (functional, not decorative) */ --shadow-sm: 0 1px 2px rgba(0,0,0,.04); --shadow-md: 0 2px 8px rgba(0,0,0,.06); - --shadow-focus: 0 0 0 3px rgba(0,113,227,.25); + --shadow-focus: 0 0 0 3px rgba(99,102,241,.25); /* Legacy aliases */ --radius: var(--radius-lg); @@ -53,14 +53,14 @@ --border: #48484a; --text: #f5f5f7; --text-muted: #98989d; - --accent: #0a84ff; - --accent-hover: #409cff; + --accent: #6366f1; + --accent-hover: #4f46e5; --success: #30d158; --error: #ff453a; --warning: #ff9f0a; --shadow-sm: 0 1px 2px rgba(0,0,0,.2); --shadow-md: 0 2px 8px rgba(0,0,0,.3); - --shadow-focus: 0 0 0 3px rgba(10,132,255,.35); + --shadow-focus: 0 0 0 3px rgba(99,102,241,.35); --tab-active-bg: #2c2c2e; } } @@ -108,12 +108,11 @@ select:focus-visible { /* ── Header ────────────────────────────────────────────────────── */ .header { display: flex; - align-items: center; justify-content: space-between; - padding: 12px 16px; - background: var(--surface); + align-items: center; + padding: 16px 24px; + background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); border-bottom: 1px solid var(--border); - -webkit-app-region: drag; } .logo { @@ -166,7 +165,12 @@ select:focus-visible { transition: color 0.15s, background 0.15s; } -.tab:hover { color: var(--text); background: var(--surface2); } +.tab:hover { + color: var(--text); + background: var(--surface2); + transform: scale(1.02); + transition: transform 0.1s ease, background 0.15s ease; +} .tab.active { color: var(--accent); background: var(--tab-active-bg); @@ -192,11 +196,11 @@ select:focus-visible { } .metric-card { - background: var(--surface); + background: linear-gradient(145deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); border: 1px solid var(--border); - border-radius: var(--radius); - padding: 14px; - box-shadow: var(--shadow); + border-radius: 12px; + padding: 20px; + transition: transform 0.2s, box-shadow 0.2s; } .metric-label { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; } From 351a0feb0e37b158fb35e61ae05d695af1c9cfc1 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 21:49:10 +0200 Subject: [PATCH 39/59] fix: update token handling in AnalyticsModule for consistency with new data structure --- internal/gui/assets/app.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 082f7e55..b2b0785a 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -1774,10 +1774,10 @@ const AnalyticsModule = { const s = data.summary || {}; const fmt = (n) => n != null ? Number(n).toLocaleString() : '—'; document.getElementById('kpi-requests').textContent = fmt(s.total_requests); - const totTok = (s.total_tokens_input||0) + (s.total_tokens_output||0); + 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.total_tokens_input); - document.getElementById('kpi-tokens-out').textContent = fmt(s.total_tokens_output); + document.getElementById('kpi-tokens-in').textContent = fmt(s.input_tokens); + document.getElementById('kpi-tokens-out').textContent = fmt(s.output_tokens); const cost = s.estimated_cost_usd != null ? '$' + Number(s.estimated_cost_usd).toFixed(2) : '—'; document.getElementById('kpi-cost').textContent = cost; From 2ee733aed3d5e3bc2bc96986aefa8c2bd435799c Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 21:56:57 +0200 Subject: [PATCH 40/59] Update internal/gui/assets/style.css Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> Signed-off-by: TUYIZERE Samuel --- internal/gui/assets/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gui/assets/style.css b/internal/gui/assets/style.css index d692e601..cdff2b06 100644 --- a/internal/gui/assets/style.css +++ b/internal/gui/assets/style.css @@ -60,7 +60,7 @@ --warning: #ff9f0a; --shadow-sm: 0 1px 2px rgba(0,0,0,.2); --shadow-md: 0 2px 8px rgba(0,0,0,.3); - --shadow-focus: 0 0 0 3px rgba(99,102,241,.35); + --shadow-focus: 0 0 0 3px rgba(99,102,241,.35); --tab-active-bg: #2c2c2e; } } From f2dc2c6de4c62cdb131770822d2c21b6f0dcf83d Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 21:57:20 +0200 Subject: [PATCH 41/59] Update internal/gui/assets/style.css Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> Signed-off-by: TUYIZERE Samuel --- internal/gui/assets/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gui/assets/style.css b/internal/gui/assets/style.css index cdff2b06..4c977ba9 100644 --- a/internal/gui/assets/style.css +++ b/internal/gui/assets/style.css @@ -113,7 +113,7 @@ select:focus-visible { padding: 16px 24px; background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); border-bottom: 1px solid var(--border); -} + -webkit-app-region: drag; .logo { display: flex; From 4e204473e09c691a08956952155bd6176e624747 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 21:57:41 +0200 Subject: [PATCH 42/59] Update internal/gui/assets/index.html Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> Signed-off-by: TUYIZERE Samuel --- internal/gui/assets/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index a81ee150..b90e6f4d 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -581,7 +581,7 @@ - +
From 7506614934ad57c6c5ae208bdf186b7cfa290295 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 21:58:02 +0200 Subject: [PATCH 43/59] Update internal/gui/assets/index.html Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> Signed-off-by: TUYIZERE Samuel --- internal/gui/assets/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index b90e6f4d..ed1d3fe2 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -37,7 +37,7 @@ - + From ca9643e867bdfdb23f0f301c7475cf780fe88e6a Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 21:58:56 +0200 Subject: [PATCH 44/59] fix: add estimated cost calculation to TokenSummary in GetTokenSummary method --- internal/storage/analytics.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/storage/analytics.go b/internal/storage/analytics.go index c5c774c6..2e19e604 100644 --- a/internal/storage/analytics.go +++ b/internal/storage/analytics.go @@ -21,6 +21,7 @@ type TokenSummary struct { InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` SuccessRate float64 `json:"success_rate"` // 0-1 + EstCostUSD float64 `json:"estimated_cost_usd"` PeriodStart time.Time `json:"period_start"` PeriodEnd time.Time `json:"period_end"` } @@ -40,19 +41,25 @@ func (a *Analytics) GetTokenSummary(days int) (*TokenSummary, error) { summary.PeriodEnd = time.Now() row := a.db.DB().QueryRowContext(ctx, ` - SELECT + SELECT COUNT(*) AS total_requests, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, - CASE + CASE WHEN COUNT(*) > 0 THEN CAST(SUM(success) AS FLOAT) / COUNT(*) - ELSE 0 - END AS success_rate - FROM requests - WHERE created_at >= ? + ELSE 0 + END AS success_rate, + COALESCE( + (SUM(input_tokens) * COALESCE(m.cost_input_per_m, 0) + + SUM(output_tokens) * COALESCE(m.cost_output_per_m, 0)) / 1000000, + 0 + ) AS est_cost_usd + FROM requests r + LEFT JOIN models m ON m.id = r.model + WHERE r.created_at >= ? `, since.Format(time.RFC3339Nano)) - if err := row.Scan(&summary.TotalRequests, &summary.InputTokens, &summary.OutputTokens, &summary.SuccessRate); err != nil { + if err := row.Scan(&summary.TotalRequests, &summary.InputTokens, &summary.OutputTokens, &summary.SuccessRate, &summary.EstCostUSD); err != nil { return nil, err } return &summary, nil From 3507c577ff2ce1812348ed5edb16e08f830ae00a Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 22:23:51 +0200 Subject: [PATCH 45/59] fix: correct JSON field name for estimated cost in TokenSummary and update cost calculation logic --- internal/storage/analytics.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/storage/analytics.go b/internal/storage/analytics.go index 2e19e604..d2c2deea 100644 --- a/internal/storage/analytics.go +++ b/internal/storage/analytics.go @@ -21,7 +21,7 @@ type TokenSummary struct { InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` SuccessRate float64 `json:"success_rate"` // 0-1 - EstCostUSD float64 `json:"estimated_cost_usd"` + EstCostUSD float64 `json:"est_cost_usd"` PeriodStart time.Time `json:"period_start"` PeriodEnd time.Time `json:"period_end"` } @@ -50,8 +50,8 @@ func (a *Analytics) GetTokenSummary(days int) (*TokenSummary, error) { ELSE 0 END AS success_rate, COALESCE( - (SUM(input_tokens) * COALESCE(m.cost_input_per_m, 0) + - SUM(output_tokens) * COALESCE(m.cost_output_per_m, 0)) / 1000000, + (SUM(input_tokens * COALESCE(m.cost_input_per_m, 0)) + + SUM(output_tokens * COALESCE(m.cost_output_per_m, 0))) / 1000000, 0 ) AS est_cost_usd FROM requests r @@ -100,8 +100,8 @@ func (a *Analytics) GetModelBreakdown(days int) ([]ModelBreakdown, error) { ELSE 0 END AS success_rate, COALESCE( - (SUM(r.input_tokens) * COALESCE(m.cost_input_per_m, 0) + - SUM(r.output_tokens) * COALESCE(m.cost_output_per_m, 0)) / 1000000, + (SUM(r.input_tokens * COALESCE(m.cost_input_per_m, 0)) + + SUM(r.output_tokens * COALESCE(m.cost_output_per_m, 0))) / 1000000, 0 ) AS est_cost_usd FROM requests r From b68c780757fc08f8b08bbf93038d09017e0a8356 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 22:50:01 +0200 Subject: [PATCH 46/59] fix: update estimated cost field name in AnalyticsModule --- internal/gui/assets/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index b2b0785a..57d8521b 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -1778,7 +1778,7 @@ const AnalyticsModule = { 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.estimated_cost_usd != null ? '$' + Number(s.estimated_cost_usd).toFixed(2) : '—'; + const cost = s.est_cost_usd != null ? '$' + Number(s.est_cost_usd).toFixed(2) : '—'; document.getElementById('kpi-cost').textContent = cost; // p95 avg From 7528b429bdbad1fbb6d11cadc0aac02752b495ff Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 22:55:17 +0200 Subject: [PATCH 47/59] fix: add .gstack/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5dbcb525..4ea97fad 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ brag-output-** router.test .playwright-mcp +.gstack/ From 38989aa43282edbc7c4d4d20f2f53224d4335c17 Mon Sep 17 00:00:00 2001 From: TUYIZERE Samuel Date: Sun, 12 Jul 2026 23:47:03 +0200 Subject: [PATCH 48/59] Refactor code structure for improved readability and maintainability --- internal/gui/assets/app.js | 6 +- internal/gui/assets/index.html | 534 +++++----- internal/gui/assets/style.css | 1698 ++++---------------------------- internal/gui/server.go | 2 +- 4 files changed, 457 insertions(+), 1783 deletions(-) diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 57d8521b..715c2551 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -946,7 +946,7 @@ function showHistoryDetail(record) {
Status - ${record.success ? 'Success' : 'Failed'} + ${record.success ? 'Success' : 'Failed'}
`; modal.classList.add('visible'); @@ -1173,14 +1173,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 = ` -
+
diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index ed1d3fe2..6b5545ab 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -1,102 +1,107 @@ - + routatic-proxy Console + + - - + -
-
-
-