diff --git a/.github/scripts/get-versions.sh b/.github/scripts/get-versions.sh new file mode 100755 index 00000000..80616f8a --- /dev/null +++ b/.github/scripts/get-versions.sh @@ -0,0 +1,95 @@ +#!/bin/sh +# get-versions.sh - Detects versions for dual release channel system +# +# This script: +# 1. Gets the latest production version from releases branch tags +# 2. Generates a beta version with timestamp (industry standard: base on upcoming version + timestamp) +# 3. Outputs both versions as JSON +# +# Version Format: +# - Beta: v{UPCOMING_VERSION}-beta.{YYYYMMDD.HHMMSS} +# - Example: v1.3.0-beta.20260712.143015 +# - The timestamp ensures uniqueness even with multiple beta releases in a day + +set -eu + +# Configuration +RELEASES_BRANCH="origin/releases" +TAG_PATTERN="v[0-9]*" + +# Default fallback version (used when no tags found) +DEFAULT_VERSION="v0.0.0" + +# Get current UTC timestamp for beta version +# Format: YYYYMMDD.HHMMSS (dot separator for SemVer compatibility) +get_timestamp() { + date -u +"%Y%m%d.%H%M%S" +} + +# Get the latest production version from releases branch +get_prod_version() { + # Fetch tags from the releases branch + git fetch "${RELEASES_BRANCH}" 2>/dev/null || true + + # Get the latest tag matching the pattern + # Sort versions in descending order and take the first one + latest_tag=$(git tag -l "${TAG_PATTERN}" --sort=-version:refname | head -1) + + # If no tags found, use default + if [ -z "${latest_tag}" ]; then + echo "${DEFAULT_VERSION}" + else + echo "${latest_tag}" + fi +} + +# Increment minor version for beta (e.g., v1.2.3 → v1.3.0) +increment_minor_version() { + local prod_version="$1" + # Parse version components using sed (POSIX compliant) + major=$(echo "${prod_version}" | sed 's/^v\([0-9]*\)\..*/\1/') + minor=$(echo "${prod_version}" | sed 's/^v[0-9]*\.\([0-9]*\).*/\1/') + + if [ -z "${major}" ] || [ -z "${minor}" ]; then + echo "${DEFAULT_VERSION}" + return + fi + + # Increment minor version, reset patch to 0 + new_minor=$((minor + 1)) + echo "v${major}.${new_minor}.0" +} + +# Generate beta version based on upcoming version + timestamp +# Industry standard: v{UPCOMING_VERSION}-beta.{TIMESTAMP} +# Example: v1.3.0-beta.20260712.143015 +generate_beta_version() { + upcoming_version=$(increment_minor_version "$1") + timestamp="$2" + echo "${upcoming_version}-beta.${timestamp}" +} + +# Output JSON +output_json() { + prod_version="$1" + upcoming_version=$(increment_minor_version "$prod_version") + beta_version="$2" + + printf '{ + "prod_version": "%s", + "upcoming_version": "%s", + "beta_version": "%s" +} +' "${prod_version}" "${upcoming_version}" "${beta_version}" +} + +# Main execution +main() { + prod_version=$(get_prod_version) + timestamp=$(get_timestamp) + beta_version=$(generate_beta_version "${prod_version}" "${timestamp}") + + output_json "${prod_version}" "${beta_version}" +} + +main diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml new file mode 100644 index 00000000..e85edd5c --- /dev/null +++ b/.github/workflows/beta-release.yml @@ -0,0 +1,270 @@ +name: Beta Release + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + # ── Stage 1: Test & Build Validation ────────────────────────────── + validate: + name: Test & Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: "1.25" + + - name: Download dependencies + run: go mod download + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... -v -race + + - name: Build (sanity check) + run: go build -o /dev/null ./cmd/routatic-proxy + + # ── Stage 2: Build & Release (macOS runner for DMG support) ────── + release: + name: Build & Release + needs: validate + runs-on: macos-latest + outputs: + beta_tag: ${{ steps.version.outputs.beta_tag }} + prod_version: ${{ steps.version.outputs.prod_version }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: "1.25" + cache: true + + - name: Get versions + id: version + run: | + # Make script executable and run it + chmod +x .github/scripts/get-versions.sh + + # Run script and capture JSON output + VERSIONS_JSON=$(.github/scripts/get-versions.sh) + + # Extract values using jq + PROD_VERSION=$(echo "$VERSIONS_JSON" | jq -r '.prod_version') + BETA_VERSION=$(echo "$VERSIONS_JSON" | jq -r '.beta_version') + + echo "prod_version=${PROD_VERSION}" >> "$GITHUB_OUTPUT" + echo "beta_tag=${BETA_VERSION}" >> "$GITHUB_OUTPUT" + echo "version=${BETA_VERSION#v}" >> "$GITHUB_OUTPUT" + + echo "Production version: ${PROD_VERSION}" + echo "Beta tag: ${BETA_VERSION}" + + - name: Build cross-platform binaries + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + mkdir -p dist + + LDFLAGS="-X main.version=${VERSION}" + + # Define target platforms (bash 3 compatible — macOS default) + TARGETS="darwin-amd64:darwin/amd64 darwin-arm64:darwin/arm64 linux-amd64:linux/amd64 linux-arm64:linux/arm64 windows-amd64:windows/amd64 windows-arm64:windows/arm64" + + for TARGET in $TARGETS; do + NAME="${TARGET%%:*}" + PAIR="${TARGET##*:}" + GOOS="${PAIR%/*}" + GOARCH="${PAIR#*/}" + EXT="" + [ "$GOOS" = "windows" ] && EXT=".exe" + + echo "Building ${NAME}..." + CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \ + go build -ldflags "$LDFLAGS -s -w" \ + -o "dist/routatic-proxy_${NAME}${EXT}" \ + ./cmd/routatic-proxy + done + + echo "" + echo "Built binaries:" + ls -lh dist/ + + - name: Generate checksums + run: | + cd dist + sha256sum routatic-proxy_* > checksums.txt + cat checksums.txt + + - name: Build CGO-enabled binary (for DMG) + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + CGO_ENABLED=1 go build -tags darwin -ldflags "-X main.version=${VERSION}" -o bin/routatic-proxy ./cmd/routatic-proxy + + - name: Build macOS DMG + env: + VERSION: ${{ steps.version.outputs.beta_tag }} + run: | + brew install create-dmg + make dmg VERSION=${{ env.VERSION }} + + - name: Generate AI Changelog + id: changelog + env: + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + OPENROUTER_MODEL: ${{ secrets.OPENROUTER_MODEL }} + OPENROUTER_TEMPERATURE: ${{ secrets.OPENROUTER_TEMPERATURE }} + OPENROUTER_MAX_TOKENS: ${{ secrets.OPENROUTER_MAX_TOKENS }} + run: | + brew install jq + + # Configuration with defaults + MODEL="${OPENROUTER_MODEL:-openai/gpt-4o-mini}" + TEMP="${OPENROUTER_TEMPERATURE:-0.3}" + TOKENS="${OPENROUTER_MAX_TOKENS:-2000}" + + echo "Using OpenRouter model: $MODEL" + + # Generate changelog for commits since previous beta tag or prod tag + PREVIOUS_TAG=$(git describe --tags --abbrev=0 --match "v*-*" 2>/dev/null || git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "") + + if [ -n "$PREVIOUS_TAG" ]; then + echo "Generating AI changelog from $PREVIOUS_TAG to ${{ steps.version.outputs.beta_tag }}" + + COMMITS=$(git log "${PREVIOUS_TAG}..HEAD" --pretty=format:"%H%n%s%n%b%n---COMMIT_SEP---") + FILE_CHANGES=$(git diff --stat "${PREVIOUS_TAG}..HEAD") + + PROMPT="You are a technical writer generating release notes for a Go proxy server project (routatic-proxy). + + Analyze the provided git commits and file changes, then generate a well-structured + changelog in Markdown format. Follow these guidelines: + + 1. Start with a brief summary paragraph (2-3 sentences) describing the overall theme of this beta release + 2. Group changes into sections with these exact headers: + - **New Features** - New capabilities, enhancements, additions + - **Bug Fixes** - Fixes for reported or discovered issues + - **Improvements** - Performance, reliability, or code quality improvements + - **Documentation** - README, docs, comments, config updates + - **Chores** - Build, CI/CD, dependency updates, refactoring + 3. Each bullet should be concise but descriptive (one line) + 4. Mention breaking changes prominently with a warning emoji Breaking Changes section at the top + 5. Use present tense, imperative mood (e.g., Add feature not Added feature) + 6. Keep it technical but accessible - target audience is developers using this tool + 7. This is a beta release - include a note at the end about testing and feedback + + Format output as clean Markdown without any preamble or explanation. Do not wrap the entire response in a code block." + + USER_CONTENT="Git commits since ${PREVIOUS_TAG}:\n\n${COMMITS}\n\nFiles changed:\n\n${FILE_CHANGES}" + + # Truncate if too long (~4 chars per token) + MAX_CHARS=12000 + if [ "${#USER_CONTENT}" -gt "$MAX_CHARS" ]; then + USER_CONTENT="${USER_CONTENT:0:MAX_CHARS}\n\n[...truncated for length...]" + fi + + RESPONSE=$(curl -s -L -X POST "https://openrouter.ai/api/v1/chat/completions" \ + -H "Authorization: Bearer ${OPENROUTER_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg prompt "$PROMPT" \ + --arg user "$USER_CONTENT" \ + --arg model "$MODEL" \ + --arg temp "$TEMP" \ + --arg tokens "$TOKENS" \ + '{ + model: $model, + messages: [ + {role: "system", content: $prompt}, + {role: "user", content: $user} + ], + temperature: ($temp | tonumber), + max_tokens: ($tokens | tonumber) + }' \ + )" \ + ) + + CHANGELOG=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // empty') + + if [ -z "$CHANGELOG" ] || [ "$CHANGELOG" = "null" ]; then + echo "Warning: AI changelog generation failed, using commit list instead" >&2 + echo "API response: $RESPONSE" >&2 + CHANGELOG="## Commits\n\n$(git log \"${PREVIOUS_TAG}..HEAD\" --pretty=format:'- %s')" + fi + else + echo "No previous tag found, using commit list" + CHANGELOG="## Commits\n\n$(git log --pretty=format:'- %s' -20)" + fi + + echo "$CHANGELOG" > changelog.md + cat changelog.md + + # GITHUB_TOKEN works here — same repo + - name: Create GitHub Prerelease + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + with: + tag_name: ${{ steps.version.outputs.beta_tag }} + name: Beta Release ${{ steps.version.outputs.beta_tag }} + body_path: changelog.md + draft: false + prerelease: true + files: | + dist/routatic-proxy_* + dist/checksums.txt + bin/RoutaticProxy.dmg + + # ── Stage 3: Publish Docker Image ─────────────────────────────── + docker: + name: Publish Docker Image + needs: release + permissions: + contents: read + packages: write + if: github.repository == 'routatic/proxy' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + id: meta + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=${{ needs.release.outputs.beta_tag }} + type=raw,value=beta-${{ needs.release.outputs.prod_version }} + + - name: Strip v prefix from version + id: version + env: + TAG: ${{ needs.release.outputs.beta_tag }} + run: echo "value=${TAG#v}" >> "$GITHUB_OUTPUT" + + - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: VERSION=${{ steps.version.outputs.value }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 560a3a60..6d105a4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,13 @@ name: Release on: push: - branches: [main] - tags: - - "v*" + branches: [releases] + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., v1.2.3)' + required: true + type: string permissions: contents: write @@ -15,9 +19,11 @@ jobs: name: Test & Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@v4 - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + - uses: actions/setup-go@v5 with: go-version: "1.25" @@ -41,11 +47,13 @@ jobs: outputs: tag: ${{ steps.version.outputs.tag }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + - uses: actions/setup-go@v5 with: go-version: "1.25" cache: true @@ -53,26 +61,21 @@ jobs: - name: Determine version id: version run: | - # Check if there's a tag on this commit - EXACT_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "") - if [ -n "$EXACT_TAG" ]; then - TAG="$EXACT_TAG" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + # Use user-provided version from workflow_dispatch + TAG="${{ github.event.inputs.version }}" else - # Auto-increment with rollover (0-9 per segment) - LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") - BASE=${LATEST_TAG#v} - IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE" - PATCH=$((PATCH + 1)) - if [ "$PATCH" -gt 9 ]; then - PATCH=0 - MINOR=$((MINOR + 1)) - if [ "$MINOR" -gt 9 ]; then - MINOR=0 - MAJOR=$((MAJOR + 1)) - fi - fi - TAG="v${MAJOR}.${MINOR}.${PATCH}" + # Push to releases branch - require version to be specified via workflow_dispatch + echo "Error: Pushes to releases branch must be done via workflow_dispatch with a version specified" + exit 1 + fi + + # Validate version format (must start with 'v' followed by semver) + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "Error: Version must follow semantic versioning with 'v' prefix (e.g., v1.2.3)" + exit 1 fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" echo "Releasing as $TAG" @@ -217,7 +220,8 @@ jobs: # GITHUB_TOKEN works here — same repo - name: Create GitHub Release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.version.outputs.tag }} name: Release ${{ steps.version.outputs.tag }} @@ -239,19 +243,25 @@ jobs: if: github.repository == 'routatic/proxy' runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 - - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 - uses: docker/login-action@v3 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 - uses: docker/metadata-action@v5 + - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + - uses: docker/metadata-action@v5 id: meta with: images: ghcr.io/${{ github.repository }} @@ -267,7 +277,8 @@ jobs: TAG: ${{ needs.release.outputs.tag }} run: echo "value=${TAG#v}" >> "$GITHUB_OUTPUT" - - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 - uses: docker/build-push-action@v6 + - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + - uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 @@ -285,7 +296,8 @@ jobs: if: github.repository == 'routatic/proxy' runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@v4 - name: Download release assets env: @@ -417,7 +429,8 @@ jobs: if: github.repository == 'routatic/proxy' runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@v4 - name: Download release assets env: diff --git a/.gitignore b/.gitignore index ae5f686d..46662f18 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ brag-output-** .claude router.test +.playwright-mcp +.gstack/ +node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md index 7fed145f..67d7bbc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,24 +13,22 @@ make clean # Remove build artifacts make install # Build and install to $GOPATH/bin make dist # Cross-compile for all platforms -## Build with tray support (Linux/macOS) -# Linux: sudo dnf install libappindicator-gtk3-devel # Fedora/RHEL -# Linux: sudo apt install libayatana-appindicator3-dev # Ubuntu/Debian -CGO_ENABLED=1 make build +# Start proxy with dashboard (recommended) +./bin/routatic-proxy start -## The 'ui' command opens the GUI dashboard -./bin/routatic-proxy ui # Browser-based on Linux, native window on macOS +# Start proxy only (headless) +./bin/routatic-proxy serve ``` -### Platform-specific notes +### Architecture -**Linux:** The default build uses `CGO_ENABLED=0` and opens the GUI in your default browser via `xdg-open`. For system tray support, build with `CGO_ENABLED=1` after installing the `libappindicator-gtk3-devel` (Fedora/RHEL) or `libayatana-appindicator3-dev` (Ubuntu/Debian) package. +**routatic-proxy start** runs both the proxy server and GUI dashboard: +- Proxy listens on `127.0.0.1:3456` (configurable) +- Dashboard at `http://127.0.0.1:3445` +- Usage data persists to SQLite (`~/.local/share/routatic-proxy/data.db`) regardless of dashboard state +- Press Ctrl+C to stop both servers -**macOS:** The `ui` command opens a native window with system tray integration. Requires CGO with Cocoa headers. For builds without CGO, use `CGO_ENABLED=0 make build` which opens the browser-based GUI. - -**Windows:** The `ui` command is not supported. Use CLI only or run the proxy with `make run`. - -Run a single test: `go test ./internal/router/ -v` +**routatic-proxy serve** runs headless (no dashboard). ## Architecture @@ -112,16 +110,15 @@ Precedence: `*_API_KEYS` → `*_API_KEY` → global `API_KEYS` → global `API_K ## Key Files - `cmd/routatic-proxy/main.go` — CLI entry point (cobra). Default config template is generated here. -- `cmd/routatic-proxy/ui_darwin.go` — macOS GUI entry point (`routatic-proxy ui`), webview + tray integration (darwin-only build tag). - `internal/config/` — Config types and JSON loader with `${VAR}` env interpolation. - `internal/transformer/` — Request/response format conversion (Anthropic ↔ OpenAI). - `internal/router/fallback.go` — Circuit breaker per model (3 failures = 30s skip). - `configs/config.example.json` — Reference config with all options documented. -- `internal/gui/` — Embedded HTTP server for the webview dashboard (serves static assets + API endpoints). -- `internal/gui/assets/` — HTML/CSS/JS for the dashboard (Overview, History, Settings tabs). -- `internal/tray/` — macOS system tray icon and menu (darwin-only build tag). +- `internal/gui/` — Embedded HTTP server for the dashboard (serves static assets + API endpoints). +- `internal/gui/assets/` — HTML/CSS/JS for the dashboard (Overview, History, Analytics, Settings tabs). - `internal/history/` — In-memory ring buffer (1000 entries, O(1) insert, thread-safe). - `internal/metrics/` — In-process request counters (received, streamed, success, failed, model distribution). +- `internal/storage/` — SQLite persistence layer for request history, latency samples, and analytics. ### GUI Config Editing @@ -136,6 +133,68 @@ The Settings tab exposes all config fields as editable form inputs. On save, onl **Nil safety:** The `/api/metrics` and `/api/history` handlers handle nil dependencies gracefully — they return zero values instead of panicking if the history or metrics instance is unavailable. +## Dual Release Channel System + +This project uses a dual release channel system for separating beta and production releases: + +### Beta Channel (Automatic) +- **Trigger:** Every push to `main` branch (see `.github/workflows/beta-release.yml`) +- **Version format:** `v{UPCOMING}.beta.{YYYYMMDD.HHMMSS}` (e.g., `v1.3.0-beta.20260712.143015`) +- **GitHub release:** Marked as `prerelease: true` +- **Docker tags:** `v{UPCOMING}.beta.{YYYYMMDD.HHMMSS}` and `beta-{UPCOMING}` + +Beta releases are fully automated and include: +- Test suite validation +- Cross-platform binary builds (darwin-amd64/arm64, linux-amd64/arm64, windows-amd64/arm64) +- macOS DMG with CGO-enabled binary +- AI-generated changelog from commits +- Docker images for linux/amd64 and linux/arm64 + +### Production Channel (Manual) +- **Trigger:** Manual `workflow_dispatch` on `releases` branch (see `.github/workflows/release.yml`) +- **Version format:** `vX.Y.Z` (semantic versioning) +- **GitHub release:** Marked as `prerelease: false` (stable) +- **Docker tags:** `vX.Y.Z`, `vX.Y`, `vX`, `latest` + +Production releases include all beta features plus: +- Homebrew tap update (requires `HOMEBREW_PAT` secret) +- Scoop bucket update (requires `SCOOP_PAT` secret) + +### Version Detection Script + +`.github/scripts/get-versions.sh` is used by the beta workflow to: +1. Fetch tags from the `origin/releases` branch to get current production version (e.g., `v1.2.3`) +2. Increment to the next version (e.g., `v1.3.0`) - **beta is based on upcoming release** +3. Generate beta version by appending `.beta.{YYYYMMDD.HHMMSS}` - **timestamp ensures uniqueness even with multiple releases per day** +4. Output both versions as JSON for CI consumption + + +**Version Format Explanation:** +- `v1.3.0` = The upcoming production version (minor incremented from latest production) +- `beta.20260712.143015` = Timestamp-based prerelease tag +- Full example: `v1.3.0-beta.20260712.143015` + +### Creating a Production Release + +1. Merge all changes to `main` and verify via beta +2. Ensure `releases` branch exists and is up-to-date +3. Go to GitHub Actions → Release workflow +4. Click "Run workflow" +5. Enter version (must follow `vX.Y.Z` format) +6. Workflow validates, builds, and releases + +### Release Workflow Stages + +Both workflows share the same stages: + +1. **validate** — Run `go vet`, `go test -race`, and build sanity check on ubuntu-latest +2. **release** — Build cross-platform binaries and macOS DMG on macos-latest +3. **docker** — Publish multi-arch Docker images on ubuntu-latest + +Production adds: +4. **homebrew** — Update the homebrew-tap formula +5. **scoop** — Update the scoop-bucket manifest + ## Skill routing When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 00400ef0..1fc1bef1 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -211,6 +211,218 @@ routatic-proxy supports three providers for upstream API calls: Set `wire_format: "anthropic"` for models that need raw Anthropic Messages format (e.g., Claude on Bedrock). Requires `anthropic_base_url` to be configured. +### OpenRouter (`openrouter`) + +- Unified API for accessing 200+ models from multiple providers (OpenAI, Anthropic, Google, Meta, Mistral, and more) +- Uses OpenAI Chat Completions API format +- Pay-as-you-go pricing with competitive rates +- Set `"provider": "openrouter"` in your model config to use OpenRouter + +#### Configuration Schema + +```json +{ + "openrouter": { + "name": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "${OPENROUTER_API_KEY}", + "api_keys": ["${OPENROUTER_KEY_1}", "${OPENROUTER_KEY_2}"], + "enabled": true, + "timeout_ms": 300000, + "stream_timeout_ms": 60000 + } +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | `string` | No | Provider display name (defaults to "openrouter") | +| `base_url` | `string` | No | API endpoint base URL. Default: `https://openrouter.ai/api/v1` | +| `api_key` | `string` | Yes* | Single API key for authentication. Required if `api_keys` not set | +| `api_keys` | `string[]` | Yes* | Multiple API keys for round-robin rotation. Required if `api_key` not set | +| `enabled` | `bool` | No | Whether this provider is active. Default: `true` | +| `timeout_ms` | `int` | No | Request timeout in milliseconds. Default: `300000` (5 minutes) | +| `stream_timeout_ms` | `int` | No | Per-chunk timeout during streaming. Default: `60000` (1 minute) | + +*At least one of `api_key` or `api_keys` must be configured. + +#### Environment Variable Overrides + +| Variable | Description | Precedence | +|----------|-------------|------------| +| `ROUTATIC_PROXY_OPENROUTER_API_KEY` | Single API key override | Highest | +| `ROUTATIC_PROXY_OPENROUTER_API_KEYS` | Comma-separated keys for round-robin | Highest | +| `ROUTATIC_PROXY_OPENROUTER_BASE_URL` | Custom base URL override | Highest | + +Environment variables take precedence over config file values. Config values support `${VAR}` interpolation. + +Precedence order: `*_API_KEYS` → `*_API_KEY` → config file `api_keys` → config file `api_key` + +#### Example Configurations + +**Single-key setup:** + +```json +{ + "openrouter": { + "api_key": "sk-or-v1-xxxxxxxxxxxxxxxxxxxxxxxx" + } +} +``` + +**Multi-key round-robin for load balancing:** + +```json +{ + "openrouter": { + "api_keys": [ + "sk-or-v1-key-1", + "sk-or-v1-key-2", + "sk-or-v1-key-3" + ] + } +} +``` + +**Custom base URL (for enterprise/self-hosted):** + +```json +{ + "openrouter": { + "base_url": "https://openrouter.mycompany.com/api/v1", + "api_key": "${OPENROUTER_API_KEY}", + "enabled": true + } +} +``` + +#### Integration with Cost-Based Routing + +OpenRouter works seamlessly with `cost_routing`. Use `penalty_per_provider` to adjust effective costs: + +```json +{ + "cost_routing": { + "enabled": true, + "prefer_providers": ["openrouter", "opencode-go"], + "max_context_window": 1000000, + "penalty_per_provider": { + "openrouter": 0.02, + "opencode-go": 0.0, + "aws-bedrock": 0.05 + } + } +} +``` + +Penalties are additive to the raw model cost. Example: a model costing $0.10/1M tokens on OpenRouter with a 0.02 penalty has effective cost $0.12/1M tokens. Use this to bias routing preferences without excluding providers entirely. + +#### Model Resolution via Catalog + +Models are referenced using the `provider/model-name` pattern. OpenRouter models use the `openrouter/` prefix: + +```json +{ + "model_overrides": { + "claude-opus-4": { + "provider": "openrouter", + "model_id": "anthropic/claude-opus-4", + "temperature": 0.7, + "max_tokens": 8192, + "vision": true + }, + "gpt-4o": { + "provider": "openrouter", + "model_id": "openai/gpt-4o", + "temperature": 0.7, + "max_tokens": 4096 + }, + "gemini-2.5-pro": { + "provider": "openrouter", + "model_id": "google/gemini-2.5-pro-preview-07-11", + "temperature": 0.7, + "max_tokens": 8192 + } + } +} +``` + +**Discovering models:** + +1. Visit [openrouter.ai/models](https://openrouter.ai/models) for the complete model list +2. Use the `routatic-proxy models` command to see cached catalog entries +3. Check the [OpenRouter API docs](https://openrouter.ai/docs) for pricing and context limits + +The `model_id` in your config must match OpenRouter's model identifier exactly (e.g., `anthropic/claude-opus-4`, `openai/gpt-4o`, `google/gemini-2.5-pro-preview-07-11`). + +#### Use Cases + +**Accessing specific models:** Use OpenRouter when you need models not available on other providers: + +```json +{ + "models": { + "complex": { + "provider": "openrouter", + "model_id": "anthropic/claude-opus-4", + "temperature": 0.7, + "max_tokens": 8192, + "reasoning_effort": "max" + } + } +} +``` + +**Fallback chains:** Include OpenRouter as a fallback when primary providers fail: + +```json +{ + "fallbacks": { + "default": [ + { "provider": "opencode-go", "model_id": "deepseek-v4-pro" }, + { "provider": "openrouter", "model_id": "anthropic/claude-sonnet-4.8" }, + { "provider": "openrouter", "model_id": "openai/gpt-4.1" } + ] + } +} +``` + +**Cost optimization:** Use `cost_routing` with provider penalties to automatically select the cheapest available model: + +```json +{ + "cost_routing": { + "enabled": true, + "prefer_providers": ["openrouter"], + "penalty_per_provider": { + "openrouter": -0.01 + } + } +} +``` + +**Specialized models:** Access niche models for specific tasks: + +```json +{ + "models": { + "think": { + "provider": "openrouter", + "model_id": "deepseek/deepseek-r1-free", + "temperature": 0.6, + "max_tokens": 8192 + }, + "long_context": { + "provider": "openrouter", + "model_id": "google/gemini-1.5-pro", + "temperature": 0.7, + "max_tokens": 16384, + "context_threshold": 80000 + } + } +} +``` + ## Environment Variables Environment variables override config file values. Config values also support `${VAR}` interpolation. @@ -223,6 +435,8 @@ Environment variables override config file values. Config values also support `$ | `ROUTATIC_PROXY_PORT` | Proxy listen port | `3456` | | `ROUTATIC_PROXY_OPENCODE_URL` | OpenCode Go API endpoint | `https://opencode.ai/zen/go/v1/chat/completions` | | `ROUTATIC_PROXY_OPENCODE_ZEN_URL` | OpenCode Zen API endpoint | `https://opencode.ai/zen/v1/chat/completions` | +| `ROUTATIC_PROXY_OPENROUTER_API_KEY` | OpenRouter single API key | — | +| `ROUTATIC_PROXY_OPENROUTER_API_KEYS` | OpenRouter key pool (comma-separated) | — | | `ROUTATIC_PROXY_LOG_LEVEL` | Log level: `debug`, `info`, `warn`, `error` | `info` | Legacy equivalents such as `OC_GO_CC_API_KEY`, `OC_GO_CC_CONFIG`, and `OC_GO_CC_PORT` continue to work. When both names are set, the `ROUTATIC_PROXY_*` value wins. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac6cf024..179ebf79 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,36 @@ 4. Push to your fork and open a pull request against `main` 5. Describe what your PR does and link any related issues +### Beta Releases + +When your PR is merged to `main`, a beta release is automatically created: + +- **Trigger:** Push to `main` branch +- **Version:** `vX.Y.Z-beta-YYYYMMDD-HHMMSS` (auto-generated) +- **GitHub Release:** Marked as prerelease +- **Testing:** Download and test before reporting issues + +Beta releases allow users to test new features immediately while maintaining a separate stable release channel. + +### Production Releases + +Production releases are manual and require careful testing: + +1. Ensure all changes are merged to `main` and tested via beta +2. Update the `releases` branch from `main`: `git checkout releases && git merge main` +3. Push to `releases` branch +4. Go to GitHub Actions → Release workflow +5. Click "Run workflow" and specify version (e.g., `v1.2.3`) +6. The workflow will: + - Run full test suite + - Build cross-platform binaries + - Generate AI-powered changelog + - Create GitHub release + - Publish Docker images + - Update Homebrew tap and Scoop bucket + +See [README.md](README.md#release-channels) for more details on the dual release channel system. + ### Pre-push Hooks This repository uses git hooks to ensure code quality. Install them once after cloning: diff --git a/Dockerfile b/Dockerfile index 535b98a7..9cc16120 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,4 +27,4 @@ EXPOSE 3456 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget -qO- http://localhost:3456/health || exit 1 -ENTRYPOINT ["routatic-proxy", "serve"] +ENTRYPOINT ["routatic-proxy", "start", "--headless"] diff --git a/MODELS.md b/MODELS.md index ff2c4ad8..576f127a 100644 --- a/MODELS.md +++ b/MODELS.md @@ -47,6 +47,290 @@ Comprehensive guide to OpenCode Go and Zen models with capabilities, costs, and - Set `wire_format: "anthropic"` for Claude and other Anthropic-native models - Best for: Models deployed on your own AWS infrastructure +## OpenRouter Models + +OpenRouter provides **unified access to 100+ models** from multiple providers through a single API endpoint. Instead of managing separate API keys and endpoints for each provider, you can access models from OpenAI, Anthropic, Google, Meta, Mistral, and many others through one integration. + +### Key Benefits + +| Benefit | Description | +|---------|-------------| +| **Unified API** | Single OpenAI-compatible Chat Completions endpoint for all models | +| **100+ Models** | Access to models from 20+ providers without separate integrations | +| **Dynamic Catalog** | New models automatically available when the catalog is updated | +| **Pay-as-you-go** | Per-token pricing with no subscription required | +| **Smart Routing** | Automatic fallback to alternative providers if a model is unavailable | +| **Cost Optimization** | Route to the cheapest or fastest available provider | + +### Model Naming Convention + +OpenRouter uses a `provider/model-name` format that identifies both the original provider and the specific model: + +| Format | Example | Description | +|--------|---------|-------------| +| `openai/gpt-4o` | `openai/gpt-4o` | GPT-4o via OpenAI | +| `openai/gpt-4o-mini` | `openai/gpt-4o-mini` | GPT-4o Mini via OpenAI | +| `anthropic/claude-3.5-sonnet` | `anthropic/claude-3.5-sonnet` | Claude 3.5 Sonnet via Anthropic | +| `anthropic/claude-3-opus` | `anthropic/claude-3-opus` | Claude 3 Opus via Anthropic | +| `google/gemini-2.5-pro` | `google/gemini-2.5-pro` | Gemini 2.5 Pro via Google | +| `google/gemini-2.0-flash` | `google/gemini-2.0-flash` | Gemini 2.0 Flash via Google | +| `meta-llama/llama-3.3-70b` | `meta-llama/llama-3.3-70b` | Llama 3.3 70B via Meta | +| `mistral/mistral-large` | `mistral/mistral-large` | Mistral Large via Mistral AI | +| `x-ai/grok-2` | `x-ai/grok-2` | Grok 2 via xAI | +| `deepseek/deepseek-chat` | `deepseek/deepseek-chat` | DeepSeek V3 via DeepSeek | + +### Popular OpenRouter Models + +| Model | Provider | Context Window | Input Cost ($/M) | Output Cost ($/M) | Best For | +|-------|----------|----------------|------------------|-------------------|----------| +| **Claude 3.5 Sonnet** | Anthropic | 200K | $3.00 | $15.00 | Complex reasoning, coding, analysis | +| **Claude 3 Opus** | Anthropic | 200K | $15.00 | $75.00 | Maximum quality, difficult tasks | +| **GPT-4o** | OpenAI | 128K | $2.50 | $10.00 | General purpose, vision tasks | +| **GPT-4o Mini** | OpenAI | 128K | $0.15 | $0.60 | Cost-effective, high volume | +| **Gemini 2.5 Pro** | Google | 1M | $1.25 | $10.00 | Long context, coding, reasoning | +| **Gemini 2.0 Flash** | Google | 1M | $0.10 | $0.40 | Fast responses, cost efficiency | +| **Llama 3.3 70B** | Meta | 128K | $0.12 | $0.30 | Open source, customizable | +| **Llama 3.1 405B** | Meta | 128K | $0.80 | $1.60 | Open source, high quality | +| **Mistral Large** | Mistral | 128K | $2.00 | $6.00 | European provider, GDPR compliant | +| **Grok 2** | xAI | 128K | $2.00 | $10.00 | Real-time knowledge, humor | +| **DeepSeek V3** | DeepSeek | 64K | $0.07 | $1.10 | Cost efficiency, coding | +| **DeepSeek R1** | DeepSeek | 64K | $0.55 | $2.19 | Reasoning, chain-of-thought | + +### Discovering Available Models + +Browse the complete model catalog at: **https://openrouter.ai/models** + +The catalog includes detailed information for each model: +- **Pricing**: Input and output costs per million tokens +- **Context window**: Maximum tokens the model can process +- **Tool calling**: Whether the model supports function calling +- **Vision**: Whether the model supports image input +- **Provider routing**: Available providers for each model + +You can also fetch available models programmatically via the OpenRouter API: + +```bash +curl https://openrouter.ai/api/v1/models \ + -H "Authorization: Bearer $OPENROUTER_API_KEY" +``` + +### Configuring OpenRouter Models + +Add OpenRouter models to your routatic-proxy configuration: + +```json +{ + "models": { + "default": { + "provider": "openrouter", + "model_id": "anthropic/claude-3.5-sonnet", + "temperature": 0.7, + "max_tokens": 4096 + }, + "background": { + "provider": "openrouter", + "model_id": "openai/gpt-4o-mini", + "temperature": 0.5, + "max_tokens": 2048 + }, + "complex": { + "provider": "openrouter", + "model_id": "anthropic/claude-3-opus", + "temperature": 0.7, + "max_tokens": 8192 + }, + "long_context": { + "provider": "openrouter", + "model_id": "google/gemini-2.5-pro", + "temperature": 0.7, + "max_tokens": 8192 + } + } +} +``` + +### Cost-Based Routing Integration + +When `cost_routing.enabled` is set, the selector automatically sorts models by cost and applies your routing preferences: + +```json +{ + "cost_routing": { + "enabled": true, + "prefer_providers": ["opencode-go", "openrouter"], + "penalty_per_provider": { + "openrouter": 0.05 + }, + "max_context_window": 200000 + } +} +``` + +**How cost routing works with OpenRouter:** +- Models are sorted by combined input + output rates +- Provider penalties adjust effective costs (e.g., `openrouter: 0.05` adds 5%) +- `max_context_window` filters out models that can't handle your request size +- `prefer_providers` intersects with scenario preferences for final selection + +### Model Categories Available + +| Category | Providers | Example Models | +|----------|-----------|----------------| +| **OpenAI** | OpenAI, Azure | GPT-4o, GPT-4o Mini, GPT-4 Turbo | +| **Anthropic** | Anthropic, AWS | Claude 3 Opus, Claude 3.5 Sonnet, Claude 3 Haiku | +| **Google** | Google | Gemini 2.5 Pro, Gemini 2.0 Flash, Gemini 1.5 Pro | +| **Meta** | Meta, Together, Fireworks | Llama 3.3 70B, Llama 3.1 405B, Llama 3.1 70B | +| **Mistral** | Mistral AI | Mistral Large, Mistral Medium, Mistral Small | +| **xAI** | xAI | Grok 2, Grok Beta | +| **DeepSeek** | DeepSeek | DeepSeek V3, DeepSeek R1 | +| **Specialized** | Various | Qwen, Command R+, Perplexity, and many more | + +### Example Configurations + +#### Budget-Conscious Setup +Use cheaper models for most tasks, expensive ones only when needed: + +```json +{ + "models": { + "background": { + "provider": "openrouter", + "model_id": "openai/gpt-4o-mini" + }, + "default": { + "provider": "openrouter", + "model_id": "deepseek/deepseek-chat" + }, + "complex": { + "provider": "openrouter", + "model_id": "anthropic/claude-3.5-sonnet" + } + } +} +``` + +#### Quality-First Setup +Prioritize quality for critical tasks: + +```json +{ + "models": { + "default": { + "provider": "openrouter", + "model_id": "anthropic/claude-3.5-sonnet" + }, + "complex": { + "provider": "openrouter", + "model_id": "anthropic/claude-3-opus" + }, + "long_context": { + "provider": "openrouter", + "model_id": "google/gemini-2.5-pro" + } + } +} +``` + +#### Multi-Provider Fallback +Spread requests across providers for reliability: + +```json +{ + "models": { + "default": { + "provider": "openrouter", + "model_id": "openai/gpt-4o" + }, + "fallback": { + "provider": "openrouter", + "model_id": "anthropic/claude-3.5-sonnet" + } + }, + "fallbacks": { + "default": [ + { "model_id": "openai/gpt-4o" }, + { "model_id": "anthropic/claude-3.5-sonnet" }, + { "model_id": "google/gemini-2.5-pro" } + ] + } +} +``` + +### OpenRouter-Specific Features + +#### Provider Routing Preferences +Request specific providers or enable automatic fallback: + +```json +{ + "model_overrides": { + "claude-sonnet": { + "provider": "openrouter", + "model_id": "anthropic/claude-3.5-sonnet", + "temperature": 0.7, + "max_tokens": 4096, + "extra_body": { + "provider": { + "order": ["Anthropic", "AWS"], + "allow_fallbacks": true + } + } + } + } +} +``` + +**Routing options:** +- `order`: Priority list of providers to try +- `allow_fallbacks`: Whether to try other providers if primary is down +- `ignore`: Providers to exclude from routing + +#### How OpenRouter Model Catalog Works + +OpenRouter models are resolved dynamically from the catalog system: + +1. **Catalog Location:** `~/.config/routatic-proxy/catalog/catalog.json` +2. **Resolution:** Models are keyed as `provider/model-name` (e.g., `openai/gpt-4o`, `anthropic/claude-3.5-sonnet`) +3. **Dynamic Loading:** New models are automatically available when the catalog is updated — no code changes required + +#### Model Resolution from Catalog + +The catalog system extracts the provider from the key prefix: + +```json +{ + "providers": { + "openrouter": { + "name": "OpenRouter", + "base_url": "https://openrouter.ai/api/v1", + "enabled": true + } + }, + "models": { + "openrouter/anthropic/claude-3.5-sonnet": { + "id": "openrouter/anthropic/claude-3.5-sonnet", + "name": "Claude 3.5 Sonnet", + "limit": { "context": 200000 }, + "rates": { "input": 3.0, "output": 15.0 }, + "tool_call": true, + "modalities": { "input": ["text", "image"], "output": ["text"] }, + "reasoning": false + } + } +} +``` + +- `ResolvedModel.ModelID`: The model name without provider prefix (`anthropic/claude-3.5-sonnet`) +- `ResolvedModel.CanonicalName`: The full key (`openrouter/anthropic/claude-3.5-sonnet`) + +### Benefits Summary + +1. **Access to 100+ Models:** Single API key for OpenAI, Anthropic, Google, Meta, Mistral, and more +2. **Unified API:** OpenAI-compatible Chat Completions format for all models +3. **Automatic Fallbacks:** Built-in fallback to alternative providers if a model is unavailable +4. **Cost Optimization:** Per-token pricing with routing preferences for cost efficiency +5. **No Vendor Lock-in:** Switch between providers by changing the model ID + ## Important: API Endpoints ⚠️ **Critical:** Not all models use the same API endpoint! routatic-proxy handles this automatically, but you should know: diff --git a/Makefile b/Makefile index d9564bcd..8c06f8fb 100644 --- a/Makefile +++ b/Makefile @@ -9,14 +9,18 @@ CMD = ./cmd/routatic-proxy # ── Development ──────────────────────────────────────────────────── -build: +build: build-css CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD) @ln -sf $(BINARY) bin/$(LEGACY_BINARY) -build-ui: +build-ui: build-css CGO_ENABLED=1 go build -tags darwin -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(CMD) @ln -sf $(BINARY) bin/$(LEGACY_BINARY) +build-css: + @echo "Building Tailwind CSS..." + @npx tailwindcss -i internal/gui/assets/tailwind-input.css -o internal/gui/assets/compiled-tailwind.css --minify 2>&1 | grep -v "Browserslist:" + dmg: build-ui @./scripts/build_dmg.sh "$(VERSION)" @@ -24,7 +28,7 @@ run: go run -ldflags "$(LDFLAGS)" $(CMD) test: - go test ./internal/... ./pkg/... ./cmd/... -v -race + go test ./... -v -race vet: go vet ./... diff --git a/README.md b/README.md index 673cecf9..9e821516 100644 --- a/README.md +++ b/README.md @@ -7,204 +7,147 @@ **[English](./README.md)** | [中文](./README-zh.md) -A Go CLI proxy that lets you route [Claude Code](https://docs.anthropic.com/en/docs/claude-code) requests through multiple upstream providers — [OpenCode Go](https://opencode.ai/docs/go/), [OpenCode Zen](https://opencode.ai/docs/zen/), and [AWS Bedrock](https://aws.amazon.com/bedrock/) — with automatic model selection and format transformation. +## Supported Providers -`routatic-proxy` sits between Claude Code and your chosen providers, intercepting Anthropic API requests, transforming them to the appropriate format (OpenAI, Anthropic, Responses, or Gemini), and forwarding them upstream. Claude Code thinks it's talking to Anthropic — but your requests go to the models and providers you configure. - -`oc-go-cc` remains available as a compatibility alias, and existing `OC_GO_CC_*` environment variables and `~/.config/oc-go-cc/config.json` files are still recognized. +
---- +[![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/) -## macOS GUI Version +
-This repository provides a native macOS GUI (System Tray + Console Dashboard) for `routatic-proxy`. +| Provider | Description | Best For | +|----------|-------------|----------| +| **OpenCode Go** | High-performance open-source coding models with flat-rate pricing | Daily coding, complex reasoning, cost-effective workloads | +| **OpenCode Zen** | Curated, tested models with pay-as-you-go pricing | Claude/GPT/Gemini access without multiple API keys | +| **AWS Bedrock** | Enterprise-grade models on your own AWS infrastructure | Enterprises needing data sovereignty and compliance | +| **OpenRouter** | Unified API for 100+ LLMs with automatic failover | Experimenting with models from multiple providers | +| **Anthropic** | Native Claude models with anthropic-first failover mode | Claude-first workflows with OpenCode fallback | -### Features - -- **System Tray Icon** — Control the proxy server directly from the macOS status bar (Start, Stop, Autostart, Quit) -- **Interactive Dashboard** — A beautiful native console window to view real-time request history, model usage metrics, and easily edit/save your API keys without editing JSON files -- **App DMG Installer** — Package into a standard macOS app with custom icons and launch support +--- -### How to Run +A Go CLI proxy that lets you route [Claude Code](https://docs.anthropic.com/en/docs/claude-code) requests through multiple upstream providers with automatic model selection and format transformation. -Download the compiled `.dmg` from the **Releases** page of this repository, or run the following command directly: +`routatic-proxy` sits between Claude Code and your chosen providers, intercepting Anthropic API requests, transforming them to the appropriate format (OpenAI, Anthropic, Responses, or Gemini), and forwarding them upstream. Claude Code thinks it's talking to Anthropic — but your requests go to the models and providers you configure. -```bash -# Launch with native macOS GUI -routatic-proxy ui -``` +`oc-go-cc` remains available as a compatibility alias, and existing `OC_GO_CC_*` environment variables and `~/.config/oc-go-cc/config.json` files are still recognized. --- ## Why? -OpenCode Go gives you access to powerful open coding models for **$5/month** (then $10/month). OpenCode Zen provides curated, tested models with pay-as-you-go pricing. AWS Bedrock lets you run models on your own AWS infrastructure. This proxy makes all three work seamlessly with Claude Code's interface — no patches, no forks, just set two environment variables and go. +OpenCode Go gives you access to powerful open coding models for **$5/month** (then $10/month). OpenCode Zen provides curated, tested models with pay-as-you-go pricing. AWS Bedrock lets you run models on your own AWS infrastructure. OpenRouter gives you unified access to 100+ models. This proxy makes all of them work seamlessly with Claude Code's interface — no patches, no forks, just set two environment variables and go. ## Features -- **Multi-Provider** — Route through OpenCode Go, OpenCode Zen, or AWS Bedrock from a single config +- **Multi-Provider** — Route through OpenCode Go, OpenCode Zen, AWS Bedrock, or OpenRouter from a single config - **Transparent Proxy** — Claude Code sends Anthropic-format requests, proxy transforms to provider-native format and back - **Model Routing** — Automatically routes to different models based on context (default, thinking, long context, background) -- **Streaming Scenario Routing** — Configurable routing for streaming requests; enables proper scenario selection for Claude Code multi-agent and review workflows (see [CONFIGURATION.md](CONFIGURATION.md#streaming-scenario-routing)) +- **Streaming Scenario Routing** — Configurable routing for streaming requests (see [CONFIGURATION.md](CONFIGURATION.md#streaming-scenario-routing)) - **Fallback Chains** — If a model fails, automatically tries the next one in your configured chain - **Anthropic-First Failover** — Keep Claude on Anthropic and use OpenCode only during rate limits or outages - **Circuit Breaker** — Tracks model health and skips failing models to avoid latency spikes - **Real-time Streaming** — Full SSE streaming with live format transformation -- **Tool Calling** — Proper Anthropic tool_use/tool_result <-> OpenAI/Gemini function calling translation -- **Token Counting** — Uses tiktoken (cl100k_base) for accurate token counting and context threshold detection -- **JSON Configuration** — Flexible config file with environment variable overrides and `${VAR}` interpolation -- **Hot Reload** — Watch config file for changes and reload automatically (off by default) -- **Background Mode** — Run as daemon detached from terminal -- **Auto-start on Login** — Launch on system startup via launchd (macOS) +- **Tool Calling** — Proper Anthropic tool_use/tool_result ↔ OpenAI/Gemini function calling translation +- **Hot Reload** — Watch config file for changes and reload automatically - **Self-Update** — Check and install the latest release with one command -## Supported Models - -### OpenCode Go Models - -| Model | Context | Best For | -| ------------------ | ------------ | --------------------------------------------- | -| **GLM-5.2** | ~200K tokens | Critical architecture, production code review | -| **Kimi K2.7 Code** | ~256K tokens | Large code generation, 32K max output | -| **Qwen3.7 Plus** | ~128K tokens | General coding, better quality than Qwen3.6 | -| **Qwen3.7 Max** | ~128K tokens | Complex coding, Qwen's best quality | +See [docs/architecture.md](docs/architecture.md) for system design and request flow details. -See [MODELS.md](MODELS.md) for the complete model list including costs and routing recommendations. +## GUI Version -### OpenCode Zen Models +This repository provides a cross-platform GUI for `routatic-proxy`: -Zen provides pay-as-you-go access to additional models: +- **macOS** — Native Cocoa window with system tray integration (requires CGO). Download the `.dmg` from the **Releases** page. +- **Linux** — Browser-based GUI via `xdg-open` (default, no CGO required). For system tray: build with `CGO_ENABLED=1` and install `libappindicator-gtk3-devel` (Fedora) or `libayatana-appindicator3-dev` (Ubuntu/Debian). +- **Windows** — GUI not supported (CLI only). -- **Claude Models**: Claude Fable 5, Claude Opus 4.8/4.6/4.5/4.1, Claude Sonnet 4 -- **Gemini Models**: Gemini 3.5 Flash, Gemini 3.1 Pro, Gemini 3 Flash -- **GPT Models**: GPT 5.5, GPT 5.4, GPT 5.3 Codex, and more -- **Free Tier**: Nemotron 3 Ultra Free, MiMo V2.5 Free, DeepSeek V4 Flash Free, and others +**Dashboard tabs:** Overview (real-time metrics & model distribution), History (last 1000 requests with filters), Settings (edit config with hot-reload). -See [MODELS.md](MODELS.md#opencodes-zen) for the full Zen model list. - -### Deprecated Models - -The following models are deprecated and will be removed: - -- GPT 5.2/5.1/5 Codex variants (replaced by GPT 5.3 Codex) -- Claude Sonnet 4 (replaced by Claude Sonnet 4.5/4.6) -- GLM 5/4.7/4.6 (replaced by GLM 5.1/5.2) -- MiniMax M2.1 (replaced by MiniMax M2.5/M2.7/M3) -- Gemini 3 Pro (replaced by Gemini 3.1 Pro) -- Kimi K2/K2 Thinking (replaced by Kimi K2.5/K2.6/K2.7 Code) +```bash +routatic-proxy ui +``` -See [MODELS.md](MODELS.md#deprecated-zen-models) for the complete deprecation schedule. +On macOS, this opens a native window. On Linux, it opens your default browser. ## Quick Start -### 1. Install - ```bash -# macOS / Linux +# 1. Install brew tap routatic/tap && brew install routatic-proxy -# Windows -scoop bucket add routatic https://github.com/routatic/scoop-bucket && scoop install routatic-proxy - -# Docker (with Makefile) -cp .env.example .env # then put your API key in .env -make docker-up - -# Docker (manual) -cp .env.example .env -docker build -t routatic-proxy . -docker run -d --restart unless-stopped --name routatic-proxy --env-file .env -p 3456:3456 routatic-proxy - -# Docker from GitHub Container Registry -docker pull ghcr.io/routatic/proxy:latest -docker run -d --restart unless-stopped --name routatic-proxy --env-file .env -p 3456:3456 ghcr.io/routatic/proxy:latest -``` - -Or see [INSTALLATION.md](INSTALLATION.md) for more options. - -### 2. Initialize Configuration - -```bash +# 2. Initialize configuration routatic-proxy init -``` - -Creates a default config at `~/.config/routatic-proxy/config.json`. Edit it to add your API key, or set the environment variable: -```bash +# 3. Set your API key export ROUTATIC_PROXY_API_KEY=sk-opencode-your-key-here -``` - -### 3. Start the Proxy -```bash +# 4. Start the proxy routatic-proxy serve -``` - -Stop the Docker container (if using Docker): - -```bash -make docker-stop -``` -### 4. Configure Claude Code - -For the default OpenCode-only mode: - -```bash +# 5. Configure Claude Code export ANTHROPIC_BASE_URL=http://127.0.0.1:3456 export ANTHROPIC_AUTH_TOKEN=unused -``` -For Anthropic-first mode, enable `anthropic_first` in the proxy config and set only the base URL. Do not set an API key or auth token: Claude Code will keep using its saved Claude subscription login. - -```bash -export ANTHROPIC_BASE_URL=http://127.0.0.1:3456 -unset ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY -``` - -Anthropic-first mode falls back on HTTP 408, 429, 5xx, and connection failures. It honors `Retry-After` and uses one real request to detect recovery, so it does not spend tokens on health checks. See [CONFIGURATION.md](CONFIGURATION.md#anthropic-first-failover). - -### 5. Run Claude Code - -```bash +# 6. Run Claude Code claude ``` +See [INSTALLATION.md](INSTALLATION.md) for Homebrew, Scoop, Docker, and build-from-source options. + ## CLI Commands ``` routatic-proxy serve Start the proxy server routatic-proxy serve -b Start in background (detached from terminal) -routatic-proxy serve --port 8080 Start on a custom port routatic-proxy stop Stop the running proxy server routatic-proxy status Check if the proxy is running routatic-proxy init Create default configuration file routatic-proxy validate Validate configuration file -routatic-proxy models List all available models (Go, Zen, Bedrock) +routatic-proxy models List all available models +routatic-proxy ui Launch the GUI dashboard routatic-proxy autostart enable Enable auto-start on login -routatic-proxy autostart disable Disable auto-start on login -routatic-proxy autostart status Check autostart status routatic-proxy update Update to the latest release -routatic-proxy update --check Show if an update is available -routatic-proxy update --yes Update without prompting routatic-proxy --version Show version ``` ## Documentation -| Document | Description | -| ------------------------------------------------------------ | --------------------------------------------------------------- | -| [INSTALLATION.md](INSTALLATION.md) | Homebrew, Scoop, build from source, release binaries | -| [CONFIGURATION.md](CONFIGURATION.md) | Config file reference, env vars, model routing, fallback chains | -| [MODELS.md](MODELS.md) | Model capabilities, costs, and routing recommendations | -| [CONTRIBUTING.md](CONTRIBUTING.md) | Development setup, architecture, how it works | -| [TROUBLESHOOTING.md](TROUBLESHOOTING.md) | Common issues and debug mode | -| [docs/fedora-setup.md](docs/fedora-setup.md) | Fedora 44 setup guide (installation, systemd, SELinux) | -| [docs/architecture.md](docs/architecture.md) | System design, request flow, module overview | -| [docs/reference-api.md](docs/reference-api.md) | HTTP API reference (endpoints, streaming, errors) | -| [docs/howto-add-model.md](docs/howto-add-model.md) | Adding new models (zero code changes) | -| [docs/howto-custom-routing.md](docs/howto-custom-routing.md) | Customizing scenario detection and model selection | -| [docs/howto-debug-routing.md](docs/howto-debug-routing.md) | Debugging routing issues and common problems | +| Document | Description | +|----------|-------------| +| [docs/models.md](docs/models.md) | Model reference across all providers | +| [docs/openrouter.md](docs/openrouter.md) | OpenRouter provider setup and configuration | +| [CONFIGURATION.md](CONFIGURATION.md) | Config file reference, env vars, model routing, fallback chains | +| [MODELS.md](MODELS.md) | Complete model capabilities, costs, and routing recommendations | +| [INSTALLATION.md](INSTALLATION.md) | Homebrew, Scoop, build from source, Docker | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Development setup, architecture | +| [TROUBLESHOOTING.md](TROUBLESHOOTING.md) | Common issues and debug mode | +| [docs/architecture.md](docs/architecture.md) | System design and request flow | +| [docs/fedora-setup.md](docs/fedora-setup.md) | Fedora 44 setup (systemd, SELinux) | +| [docs/reference-api.md](docs/reference-api.md) | HTTP API reference | +| [docs/howto-add-model.md](docs/howto-add-model.md) | Adding new models (zero code changes) | +| [docs/howto-custom-routing.md](docs/howto-custom-routing.md) | Customizing scenario detection and routing | +| [docs/howto-debug-routing.md](docs/howto-debug-routing.md) | Debugging routing issues | + +## Release Channels + +This project uses a dual release channel system. See [RELEASE_PROCESS.md](RELEASE_PROCESS.md) for full details. + +### Beta Channel (Automatic) +- **Trigger:** Every push to `main` branch +- **Version format:** `v{UPCOMING}.beta.{YYYYMMDD.HHMMSS}` (e.g., `v1.3.0-beta.20260712.143015`) +- **GitHub release:** Marked as prerelease +- **Use case:** Get the latest features and bug fixes immediately; ideal for testing + +### Production Channel (Manual) +- **Trigger:** Manual `workflow_dispatch` on `releases` branch +- **Version format:** `vX.Y.Z` (semantic versioning) +- **GitHub release:** Marked as stable +- **Docker tags:** `vX.Y.Z`, `vX.Y`, `vX`, `latest` +- **Use case:** Stable, tested releases for production use ## Contributing diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md new file mode 100644 index 00000000..b50157eb --- /dev/null +++ b/RELEASE_PROCESS.md @@ -0,0 +1,503 @@ +# Dual Release Channel System + +This document explains the dual release channel system for routatic-proxy, which supports both automated beta releases and manual production releases. + +## Table of Contents + +- [Overview](#overview) +- [Version Naming Conventions](#version-naming-conventions) +- [Beta Releases](#beta-releases) +- [Production Releases](#production-releases) +- [Promoting Beta to Production](#promoting-beta-to-production) +- [GitHub Release Channel Separation](#github-release-channel-separation) +- [Triggering Releases](#triggering-releases) +- [Troubleshooting](#troubleshooting) + +## Overview + +The project uses a dual release channel system: + +| Channel | Trigger | Branch | Version Format | GitHub Release Type | +|---------|---------|--------|----------------|---------------------| +| **Beta** | Automatic on merge | `main` | `v{prod-version}-beta-{timestamp}` | Prerelease | +| **Production** | Manual via `workflow_dispatch` | `releases` | `vX.Y.Z` (user specified) | Stable | + +### Key Differences + +- **Beta releases**: Automatically built and published when code is merged to `main`. These are marked as prereleases on GitHub and are intended for testing. +- **Production releases**: Triggered manually on the `releases` branch. These are stable releases intended for end users. + +## Version Naming Conventions + +### Beta Versions + +Format: `v{prod-version}-beta-{timestamp}` + +- `prod-version`: The current production version (e.g., `v1.2.3`) +- `timestamp`: UTC timestamp in format `YYYYMMDD-HHMMSS` + +**Example:** `v1.2.3-beta-20260712-143000` + +The beta version is automatically generated by the `.github/scripts/get-versions.sh` script, which: +1. Detects the latest production version from git tags +2. Generates a UTC timestamp +3. Combines them into the beta version format + +### Production Versions + +Format: `vX.Y.Z` (Semantic Versioning) + +- `X`: Major version (breaking changes) +- `Y`: Minor version (new features, backward compatible) +- `Z`: Patch version (bug fixes) + +**Example:** `v1.2.3` + +Production versions are **user-specified** when triggering the release workflow. The workflow does not auto-increment versions. + +## Beta Releases + +### How They Work + +Beta releases are fully automated: + +1. Developer merges a pull request to `main` +2. GitHub Actions triggers the `beta-release.yml` workflow +3. Workflow runs tests and builds +4. Creates a GitHub prerelease with: + - Cross-platform binaries (Linux, macOS, Windows) + - Docker image published to GHCR + - AI-generated changelog + - Marked as `prerelease: true` + +### Beta Release Artifacts + +Each beta release includes: + +- `routatic-proxy_darwin-amd64` - macOS Intel binary +- `routatic-proxy_darwin-arm64` - macOS Apple Silicon binary +- `routatic-proxy_linux-amd64` - Linux Intel binary +- `routatic-proxy_linux-arm64` - Linux ARM64 binary +- `routatic-proxy_windows-amd64.exe` - Windows Intel binary +- `routatic-proxy_windows-arm64.exe` - Windows ARM64 binary +- `RoutaticProxy.dmg` - macOS installer package +- `checksums.txt` - SHA256 checksums for all binaries + +### Docker Tags for Beta + +Beta releases are tagged as: +- `ghcr.io/routatic/proxy:{beta_tag}` (e.g., `v1.2.3-beta-20260712-143000`) +- `ghcr.io/routatic/proxy:beta-{prod_version}` (e.g., `beta-1.2.3`) + +## Production Releases + +### How They Work + +Production releases are triggered manually: + +1. Ensure the `releases` branch contains the code you want to release +2. Trigger the `release.yml` workflow via GitHub UI or CLI +3. Specify the version number (e.g., `v1.2.3`) +4. Workflow runs tests and builds +5. Creates a GitHub stable release +6. Updates Homebrew tap and Scoop bucket + +### Production Release Artifacts + +Same as beta releases, plus: +- Published to package managers (Homebrew, Scoop) +- Docker image tagged as `latest` + +### Docker Tags for Production + +Production releases are tagged as: +- `ghcr.io/routatic/proxy:{version}` (e.g., `v1.2.3`) +- `ghcr.io/routatic/proxy:{major}.{minor}` (e.g., `1.2`) +- `ghcr.io/routatic/proxy:{major}` (e.g., `1`) +- `ghcr.io/routatic/proxy:latest` + +## Promoting Beta to Production + +To promote a beta release to production: + +### Step 1: Merge to Releases Branch + +```bash +# Ensure you're on main and have the latest changes +git checkout main +git pull origin main + +# Checkout releases branch +git checkout releases +git pull origin releases + +# Merge main into releases +git merge main + +# Push to origin +git push origin releases +``` + +### Step 2: Trigger Production Release + +See [Triggering Releases](#triggering-releases) below for detailed instructions. + +### Version Selection + +When triggering a production release, you must specify the version. Common approaches: + +1. **Patch release** (bug fixes): Increment Z in `vX.Y.Z` + - `v1.2.3` -> `v1.2.4` + +2. **Minor release** (new features): Increment Y in `vX.Y.Z` + - `v1.2.3` -> `v1.3.0` + +3. **Major release** (breaking changes): Increment X in `vX.Y.Z` + - `v1.2.3` -> `v2.0.0` + +## GitHub Release Channel Separation + +GitHub releases are separated by the `prerelease` flag: + +### Beta Releases (Prerelease) + +- `prerelease: true` +- Appears under "Releases" with a "Pre-release" badge +- Not shown as "Latest" on the repository homepage +- Intended for testing and early adopters + +### Production Releases (Stable) + +- `prerelease: false` +- Appears as the "Latest" release on the repository homepage +- Shown to all users as the recommended version +- Triggers package manager updates + +### Viewing Releases + +Navigate to: `https://github.com/routatic/proxy/releases` + +- **Latest stable**: The most recent non-prerelease +- **All releases**: Includes both stable and prereleases +- **Tags**: All git tags (including betas without releases) + +## Triggering Releases + +### Beta Releases (Automatic) + +No manual action required. Beta releases trigger automatically when code is merged to `main`. + +To verify a beta release was created: + +```bash +# List recent beta tags +git tag -l "v*-beta-*" --sort=-version:refname | head -10 + +# Or check GitHub CLI +gh release list --repo routatic/proxy --limit 20 +``` + +### Production Releases (Manual) + +#### Option 1: GitHub Web UI + +1. Navigate to the repository: `https://github.com/routatic/proxy` +2. Click "Actions" tab +3. Select "Release" workflow from the left sidebar +4. Click "Run workflow" button +5. Select the `releases` branch from dropdown +6. Enter the version to release (e.g., `v1.2.3`) +7. Click "Run workflow" + +#### Option 2: GitHub CLI + +```bash +# Trigger a production release +gh workflow run release.yml \ + --repo routatic/proxy \ + --ref releases \ + -f version=v1.2.3 + +# Monitor the workflow run +gh run watch --repo routatic/proxy +``` + +#### Option 3: REST API + +```bash +# Trigger via GitHub API +curl -X POST \ + -H "Authorization: token YOUR_GITHUB_TOKEN" \ + -H "Accept: application/vnd.github.v3+json" \ + https://api.github.com/repos/routatic/proxy/actions/workflows/release.yml/dispatches \ + -d '{ + "ref": "releases", + "inputs": { + "version": "v1.2.3" + } + }' +``` + +### Required Permissions + +To trigger workflows, you need: +- Write access to the repository, OR +- `actions:write` permission scope for API/CLI access + +## Troubleshooting + +### Beta Release Issues + +#### Issue: Beta release not triggering after merge + +**Symptoms:** Code merged to `main` but no beta release created. + +**Diagnosis:** +```bash +# Check if the workflow file exists +cat .github/workflows/beta-release.yml + +# Check recent workflow runs +gh run list --workflow=beta-release.yml --limit 10 +``` + +**Solutions:** +1. Verify the merge was to `main` branch (not another branch) +2. Check if the workflow is disabled in GitHub Actions settings +3. Look for syntax errors in the workflow file +4. Check repository Actions permissions (Settings > Actions > General) + +#### Issue: Beta version shows wrong production version + +**Symptoms:** Beta tag shows `v0.0.0-beta-...` instead of actual version. + +**Diagnosis:** +```bash +# Check if production tags exist +git tag -l "v[0-9]*.[0-9]*.[0-9]*" --sort=-version:refname | head -5 + +# Run version script locally +./.github/scripts/get-versions.sh +``` + +**Solutions:** +1. Ensure at least one production version tag exists +2. The script falls back to `v0.0.0` if no tags match the pattern +3. Push a production tag manually if needed: `git tag v0.1.0 && git push origin v0.1.0` + +#### Issue: Docker image not published + +**Symptoms:** Beta release created but no Docker image in GHCR. + +**Diagnosis:** +```bash +# Check if docker job ran +gh run view --repo routatic/proxy --job=docker +``` + +**Solutions:** +1. Docker push only works for the main repository (not forks) +2. Verify `packages: write` permission in workflow +3. Check GHCR authentication in workflow logs + +### Production Release Issues + +#### Issue: "Version already exists" error + +**Symptoms:** Workflow fails with "tag already exists". + +**Diagnosis:** +```bash +# Check if tag exists +git tag -l "v1.2.3" + +# Check GitHub releases +gh release view v1.2.3 --repo routatic/proxy +``` + +**Solutions:** +1. Use a higher version number +2. Delete the existing tag (if it was a mistake): `git push --delete origin v1.2.3` +3. Check if a beta release already uses this version pattern + +#### Issue: Homebrew/Scoop update fails + +**Symptoms:** Release created but package managers not updated. + +**Diagnosis:** +```bash +# Check if HOMEBREW_PAT or SCOOP_PAT secrets are set +gh secret list --repo routatic/proxy +``` + +**Solutions:** +1. Verify `HOMEBREW_PAT` secret exists (for homebrew-tap repo access) +2. Verify `SCOOP_PAT` secret exists (for scoop-bucket repo access) +3. Check PAT has `repo` scope for the respective repositories +4. Verify the tap/bucket repositories exist and are accessible + +#### Issue: Workflow not appearing in Actions tab + +**Symptoms:** Can't find the Release workflow to trigger manually. + +**Solutions:** +1. Ensure the workflow file exists: `.github/workflows/release.yml` +2. Check if workflow has `workflow_dispatch` trigger configured +3. Workflow may need to be on the default branch (`main`) to appear +4. Check if the workflow was disabled due to inactivity + +### Version Script Issues + +#### Issue: get-versions.sh fails with "date: illegal option" + +**Symptoms:** Script fails on macOS with date command errors. + +**Solutions:** +1. The script uses GNU date format: `date -u +"%Y%m%d-%H%M%S"` +2. On macOS, install coreutils: `brew install coreutils` +3. Or modify script to use `gdate` instead of `date` + +#### Issue: get-versions.sh returns empty version + +**Symptoms:** Script outputs empty or malformed JSON. + +**Diagnosis:** +```bash +# Run with debug mode +bash -x ./.github/scripts/get-versions.sh +``` + +**Solutions:** +1. Ensure you're in a git repository +2. Check if `git tag` command works: `git tag -l` +3. Verify the script is executable: `chmod +x .github/scripts/get-versions.sh` + +### General Troubleshooting + +#### Check Workflow Logs + +```bash +# List recent runs +gh run list --limit 20 + +# View specific run logs +gh run view --log + +# View failed job logs +gh run view --job= --log +``` + +#### Validate Workflow Syntax + +```bash +# Install actionlint +brew install actionlint + +# Validate workflow files +actionlint .github/workflows/*.yml +``` + +#### Test Version Script Locally + +```bash +# Make script executable +chmod +x .github/scripts/get-versions.sh + +# Run and check output +./.github/scripts/get-versions.sh +``` + +#### Common Environment Variables + +The workflows expect these secrets: + +| Secret | Used In | Purpose | +|--------|---------|---------| +| `GITHUB_TOKEN` | All workflows | GitHub API access, releases | +| `OPENROUTER_API_KEY` | Beta/Release | AI changelog generation | +| `HOMEBREW_PAT` | Production | Homebrew tap updates | +| `SCOOP_PAT` | Production | Scoop bucket updates | + +--- + +## Quick Reference + +### Beta Release Flow + +``` +PR merged to main + | + v +GitHub Actions triggers + | + v +Run tests (ubuntu-latest) + | + v +Build binaries (macos-latest) + | + v +Create prerelease on GitHub + | + v +Publish Docker image to GHCR +``` + +### Production Release Flow + +``` +Manual trigger on releases branch + | + v +Specify version (e.g., v1.2.3) + | + v +Run tests (ubuntu-latest) + | + v +Build binaries (macos-latest) + | + v +Create stable release on GitHub + | + v +Publish Docker image to GHCR + | + v +Update Homebrew tap + | + v +Update Scoop bucket +``` + +### Useful Commands + +```bash +# List all tags +git tag -l --sort=-version:refname + +# List beta tags only +git tag -l "v*-beta-*" --sort=-version:refname + +# List production tags only +git tag -l "v[0-9]*.[0-9]*.[0-9]*" --sort=-version:refname + +# Delete a local tag +git tag -d v1.2.3 + +# Delete a remote tag +git push --delete origin v1.2.3 + +# Fetch all tags from remote +git fetch --tags + +# View release assets +gh release view v1.2.3 --repo routatic/proxy + +# Download release asset +gh release download v1.2.3 --repo routatic/proxy --pattern "routatic-proxy_linux-amd64" +``` + +--- + +*Last updated: 2026-07-12* diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 00000000..d35af2a1 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,283 @@ +# Code Review Guide + +This file defines the review standards for routatic-proxy. It is designed for LLM-based review tools, CI/CD pipelines, and human reviewers. Each check is tagged with its layer and severity. + +**Severity:** +- **BLOCKER** — must fix before merge +- **REQUIRED** — should fix, merge only with justification +- **ADVISORY** — best practice, consider + +**Layers:** +- `[T]` — Technical (Go idioms, safety, concurrency) +- `[L]` — Logical (correctness, edge cases, data flow) +- `[B]` — Business/Architecture (domain invariants, config, release) + +--- + +## 1. Technical Layer + +### 1.1 Go Idioms + +| # | Check | Severity | Source | +|---|-------|----------|--------| +| T1 | Exported symbols have doc comments (`// Package`, `// TypeName`, `// FuncName`) | REQUIRED | CONTRIBUTING.md | +| T2 | Constructors use `New` pattern, return pointer, apply defaults for nil/zero params | REQUIRED | `rules/auto-detected/CONSTRUCTOR_PATTERN.md` | +| T3 | `context.Context` is the first parameter in functions that accept it | REQUIRED | `rules/auto-detected/CONTEXT_PROPAGATION.md` | +| T4 | Per-attempt contexts use `context.WithTimeout`; `cancel()` is always called (defer or explicit) | REQUIRED | `rules/auto-detected/CONTEXT_PROPAGATION.md` | +| T5 | Loop boundaries check `ctx.Err()` to respect cancellation | REQUIRED | `rules/auto-detected/CONTEXT_PROPAGATION.md` | +| T6 | Errors use `fmt.Errorf("context: %w", err)` wrapping, never bare `return err` | REQUIRED | `rules/auto-detected/ERROR_HANDLING.md` | +| T7 | Sentinel errors declared as `var ErrX = errors.New("...")` at package level | ADVISORY | `rules/auto-detected/ERROR_HANDLING.md` | +| T8 | Error classification via `func IsXError(err error) bool` helpers, not `strings.Contains` | REQUIRED | `rules/auto-detected/ERROR_HANDLING.md` | +| T9 | Polymorphic Anthropic fields (`system`, `content`) use `json.RawMessage` with accessor methods | REQUIRED | `rules/auto-detected/JSON_RAWMESSAGE.md` | +| T10 | Accessors try simplest format first (string before array), fallback to raw bytes | REQUIRED | `rules/auto-detected/JSON_RAWMESSAGE.md` | +| T11 | Logging uses `log/slog` with key-value pairs, never `log.Printf` or `fmt.Println` for diagnostics | REQUIRED | `rules/auto-detected/SLOG_LOGGING.md` | +| T12 | Log levels: Debug=routine, Info=significant events, Warn=recoverable issues, Error=failures | REQUIRED | `rules/auto-detected/SLOG_LOGGING.md` | +| T13 | Nil logger defaults to `slog.Default()` | ADVISORY | `rules/auto-detected/SLOG_LOGGING.md` | +| T14 | `sync.Mutex` embedded in structs, `mu.Lock()` / `defer mu.Unlock()` pattern | REQUIRED | `rules/auto-detected/SYNC_MUTEX.md` | +| T15 | Separate mutexes for independent state (not one big lock) | ADVISORY | `rules/auto-detected/SYNC_MUTEX.md` | +| T16 | Use `sync.RWMutex` when reads dominate writes | ADVISORY | `rules/auto-detected/SYNC_MUTEX.md` | +| T17 | Ensure `gofmt` compliance (run `make lint`) | BLOCKER | CONTRIBUTING.md | +| T18 | All files compile with `CGO_ENABLED=0 go build` (default) | BLOCKER | Makefile | + +### 1.2 Memory & Resource Safety + +| # | Check | Severity | Rationale | +|---|-------|----------|-----------| +| T19 | `defer` is not used inside `for`/`for-range` loops (runs on function return, leaks resources) | BLOCKER | TOCTOU + leak pattern | +| T20 | HTTP response bodies are closed explicitly (not deferred in loops) | BLOCKER | Resource leak | +| T21 | Test-bind-then-close (TOCTOU) patterns are absent — listeners are bound once and kept | BLOCKER | Race condition | +| T22 | `defer cancel()` or explicit `cancel()` present for every `context.WithTimeout`/`WithCancel` | REQUIRED | Context leak | +| T23 | No goroutine leaks: goroutines have a shutdown signal (ctx.Done(), channel close, WaitGroup) | REQUIRED | Production reliability | +| T24 | No `panic()` in library code; recover only at top-level HTTP handler boundaries | REQUIRED | Crash safety | + +### 1.3 Frontend (HTML/CSS/JS) + +| # | Check | Severity | Rationale | +|---|-------|----------|-----------| +| T25 | No external CDN scripts or stylesheets — all assets bundled via `//go:embed` | REQUIRED | Offline capability, CSP, supply-chain | +| T26 | CSP header restricts `default-src 'self'`; only `'unsafe-inline'` for scripts and styles | REQUIRED | XSS mitigation | +| T27 | No inline `onclick`/`onchange` handlers referencing undefined functions (check against app.js) | REQUIRED | Silent failures | +| T28 | `data-i18n` keys exist in `TRANSLATIONS.en` (and `TRANSLATIONS.zh` if Chinese) | REQUIRED | i18n completeness | +| T29 | Translations use `t(key)` function, not direct string references | REQUIRED | I18n correctness | +| T30 | No `confirm()`/`prompt()`/`alert()` in production code — use modal or `${modelOptions}`; - const confirmed = confirm( - (currentLang === 'zh' ? '选择模型添加到降级链:\n\n' : 'Select a model to add:\n\n') + - this.availableModels.filter(m => !this.chains[this.currentScenario].includes(m.id)) - .map(m => `${m.display_name || m.id} (${m.provider})`).join('\n') - ); - - if (confirmed) { - const modelId = prompt( - currentLang === 'zh' ? '输入模型ID:' : 'Enter model ID:', - this.availableModels.filter(m => !this.chains[this.currentScenario].includes(m.id))[0]?.id || '' - ); - - if (modelId && !this.chains[this.currentScenario].includes(modelId)) { - const model = this.availableModels.find(m => m.id === modelId); - if (model) { - this.chains[this.currentScenario].push(modelId); - this.renderChain(); - } else { - alert(currentLang === 'zh' ? '无效的模型ID' : 'Invalid model ID'); - } - } - } - }, - removeModel(index) { - this.chains[this.currentScenario].splice(index, 1); - this.renderChain(); + const chain = this.chains[this.currentScenario]; + if (chain) { + chain.splice(index, 1); + this.renderChain(); + } }, preview() { @@ -1419,7 +1447,8 @@ const FallbackModule = { contentEl.innerHTML = '
' + t('fallback.empty') + '
'; } else { contentEl.innerHTML = '
' + - chain.map((modelId, i) => { + chain.map((entry, i) => { + const modelId = entry.model_id || entry; const model = this.availableModels.find(m => m.id === modelId); const displayName = model ? (model.display_name || model.id) : modelId; return ` @@ -1450,16 +1479,7 @@ const FallbackModule = { } try { - const patch = { - router_config: { - scenario_fallbacks: { - default: this.chains.default, - streaming: this.chains.streaming, - long_context: this.chains['long-context'] - } - } - }; - + const patch = { fallbacks: { ...this.chains } }; const r = await fetch('/api/proxy/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1707,3 +1727,191 @@ const TestModule = { document.addEventListener('DOMContentLoaded', () => TestModule.init()); +/* ── Analytics Tab (minimal, vanilla JS + SVG/CSS) ─────────────── */ +const AnalyticsModule = { + palette: ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6'], + + init() { + const daysSel = document.getElementById('analytics-days'); + const refreshBtn = document.getElementById('btn-refresh-analytics'); + if (daysSel) daysSel.addEventListener('change', () => this.load(true)); + if (refreshBtn) refreshBtn.addEventListener('click', () => this.load(true)); + }, + + loadingHtml() { + return '
Loading analytics…
'; + }, + + async load(force) { + const daysEl = document.getElementById('analytics-days'); + const days = daysEl ? daysEl.value : 30; + const genEl = document.getElementById('analytics-generated'); + if (genEl) genEl.textContent = ''; + ['kpi-requests','kpi-tokens','kpi-tokens-in','kpi-tokens-out','kpi-cost','kpi-p95'].forEach(id => { + const el = document.getElementById(id); + if (el) el.textContent = '…'; + }); + + this.showLoading(); + + try { + const [summaryRes, trendRes, latencyRes] = await Promise.all([ + fetch(`/api/analytics/summary?days=${days}`), + fetch(`/api/analytics/tokens/trend?days=${days}`), + fetch(`/api/analytics/latency?days=${days}`) + ]); + if (!summaryRes.ok) throw new Error('summary fetch failed'); + const summary = await summaryRes.json(); + const trend = trendRes.ok ? await trendRes.json() : { trend: [] }; + const latencyData = latencyRes.ok ? await latencyRes.json() : { stats: [] }; + + summary.latency = latencyData; + + this.renderKPIs(summary); + this.renderDonuts(summary); + this.renderTrend(trend.trend || []); + if (genEl) { + const ts = summary.generated_at ? new Date(summary.generated_at) : new Date(); + genEl.textContent = '· ' + ts.toLocaleDateString(undefined, {month:'short', day:'numeric'}); + } + } catch (e) { + console.error('Analytics error:', e); + this.renderEmpty('Failed to load analytics'); + if (genEl) genEl.textContent = 'Error'; + } + }, + + showLoading() { + ['model-donut','provider-donut','token-trend'].forEach(id => { + const el = document.getElementById(id); + if (el) el.innerHTML = this.loadingHtml(); + }); + }, + + renderKPIs(data) { + const s = data.summary || {}; + const fmt = (n) => n != null ? Number(n).toLocaleString() : '—'; + document.getElementById('kpi-requests').textContent = fmt(s.total_requests); + const totTok = (s.input_tokens||0) + (s.output_tokens||0); + document.getElementById('kpi-tokens').textContent = fmt(totTok); + document.getElementById('kpi-tokens-in').textContent = fmt(s.input_tokens); + document.getElementById('kpi-tokens-out').textContent = fmt(s.output_tokens); + const cost = s.est_cost_usd != null ? '$' + Number(s.est_cost_usd).toFixed(2) : '—'; + document.getElementById('kpi-cost').textContent = cost; + + const stats = (data.latency && data.latency.stats) || []; + let p95Val = '—'; + if (stats.length) { + const avg = stats.reduce((a, st) => a + (st.p95_ms || 0), 0) / stats.length; + p95Val = Math.round(avg) + ' ms'; + } + document.getElementById('kpi-p95').textContent = p95Val; + }, + + renderDonuts(summary) { + this.renderDonutChart('model-donut', summary.models || [], 'requests'); + this.renderDonutChart('provider-donut', summary.providers || [], 'requests'); + }, + + renderDonutChart(containerId, items, valKey) { + const wrap = document.getElementById(containerId); + if (!wrap) return; + wrap.innerHTML = ''; + + if (!items.length) { + wrap.innerHTML = '
No usage data yet
'; + return; + } + + const sorted = [...items].sort((a,b) => (b[valKey]||0) - (a[valKey]||0)); + let top = sorted.slice(0,5); + const rest = sorted.slice(5); + const otherVal = rest.reduce((sum,i) => sum + (i[valKey]||0), 0); + if (otherVal > 0) top.push({name: 'Other', [valKey]: otherVal}); + + const total = top.reduce((sum,i) => sum + (i[valKey]||0), 0) || 1; + let segs = ''; + let off = 0; + const legend = []; + top.forEach((it, idx) => { + const v = it[valKey] || 0; + const pct = v / total * 100; + const col = this.palette[idx % this.palette.length]; + segs += `${col} ${off.toFixed(1)}% ${(off + pct).toFixed(1)}%, `; + off += pct; + const label = it.model || it.provider || it.name || 'Unknown'; + legend.push(`
${this.escapeHtml(label)}${v}
`); + }); + + const html = `
${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 yet. Run some requests to see analytics.
'; + return; + } + + const w = 620, h = 188, pad = 30; + const maxV = Math.max(1, ...points.map(p => Math.max(p.input_tokens||0, p.output_tokens||0))); + const stepX = (w - pad*2) / Math.max(1, points.length - 1); + + const ptsIn = points.map((p,i) => { + const x = pad + i*stepX; + const y = h - pad - (p.input_tokens||0)/maxV * (h - pad*2); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + const ptsOut = points.map((p,i) => { + const x = pad + i*stepX; + const y = h - pad - (p.output_tokens||0)/maxV * (h - pad*2); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + + const pathIn = 'M' + ptsIn.join(' L'); + const pathOut = 'M' + ptsOut.join(' L'); + const areaIn = pathIn + ` L${(pad + (points.length-1)*stepX).toFixed(1)},${h-pad} L${pad},${h-pad} Z`; + const areaOut = pathOut + ` L${(pad + (points.length-1)*stepX).toFixed(1)},${h-pad} L${pad},${h-pad} Z`; + + const dots = points.map((p,i) => { + const x = (pad + i*stepX).toFixed(1); + const yIn = (h - pad - (p.input_tokens||0)/maxV*(h-pad*2)).toFixed(1); + const yOut = (h - pad - (p.output_tokens||0)/maxV*(h-pad*2)).toFixed(1); + return ``; + }).join(''); + + const svg = ` + + + + + + ${dots} + +
+ Input tokens + Output tokens +
`; + wrap.innerHTML = svg; + }, + + renderEmpty(msg = 'No usage data yet. Run some requests or configure a model to see analytics.') { + ['model-donut','provider-donut','token-trend'].forEach(id => { + const el = document.getElementById(id); + if (el) el.innerHTML = `
${msg}
`; + }); + }, + + escapeHtml(s) { + return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); + } +}; + +// Boot analytics module (listeners only; data loads when tab is clicked) +setTimeout(() => { + AnalyticsModule.init(); +}, 250); + diff --git a/internal/gui/assets/compiled-tailwind.css b/internal/gui/assets/compiled-tailwind.css new file mode 100644 index 00000000..270b8e5f --- /dev/null +++ b/internal/gui/assets/compiled-tailwind.css @@ -0,0 +1 @@ +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.visible{visibility:visible}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.bottom-2{bottom:.5rem}.right-2{right:.5rem}.top-0{top:0}.z-10{z-index:10}.my-0\.5{margin-top:.125rem;margin-bottom:.125rem}.mb-0{margin-bottom:0}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.mt-2\.5{margin-top:.625rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-6{height:1.5rem}.h-screen{height:100vh}.min-h-\[220px\]{min-height:220px}.min-h-\[240px\]{min-height:240px}.min-h-\[80px\]{min-height:80px}.w-6{width:1.5rem}.w-full{width:100%}.min-w-\[180px\]{min-width:180px}.max-w-\[800px\]{max-width:800px}.flex-1{flex:1 1 0%}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-text{-webkit-user-select:text;-moz-user-select:text;user-select:text}.resize-y{resize:vertical}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-none{border-style:none}.border-\[\#48484a\]{--tw-border-opacity:1;border-color:rgb(72 72 74/var(--tw-border-opacity,1))}.bg-\[\#1c1c1e\]{--tw-bg-opacity:1;background-color:rgb(28 28 30/var(--tw-bg-opacity,1))}.bg-\[\#2c2c2e\]{--tw-bg-opacity:1;background-color:rgb(44 44 46/var(--tw-bg-opacity,1))}.bg-\[\#3a3a3c\]{--tw-bg-opacity:1;background-color:rgb(58 58 60/var(--tw-bg-opacity,1))}.bg-transparent{background-color:transparent}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-\[\#2c2c2e\]{--tw-gradient-from:#2c2c2e var(--tw-gradient-from-position);--tw-gradient-to:rgba(44,44,46,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-\[\#3a3a3c\]{--tw-gradient-to:#3a3a3c var(--tw-gradient-to-position)}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pb-2{padding-bottom:.5rem}.pt-2{padding-top:.5rem}.text-left{text-align:left}.text-center{text-align:center}.font-\[-apple-system\2c BlinkMacSystemFont\2c \'PingFang_SC\'\2c \'Hiragino_Sans_GB\'\2c \'Segoe_UI\'\2c sans-serif\]{font-family:-apple-system,BlinkMacSystemFont,PingFang SC,Hiragino Sans GB,Segoe UI,sans-serif}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.text-\[26px\]{font-size:26px}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-tight{line-height:1.25}.tracking-wider{letter-spacing:.05em}.text-\[\#30d158\]{--tw-text-opacity:1;color:rgb(48 209 88/var(--tw-text-opacity,1))}.text-\[\#98989d\]{--tw-text-opacity:1;color:rgb(152 152 157/var(--tw-text-opacity,1))}.text-\[\#f5f5f7\]{--tw-text-opacity:1;color:rgb(245 245 247/var(--tw-text-opacity,1))}.text-\[\#ff453a\]{--tw-text-opacity:1;color:rgb(255 69 58/var(--tw-text-opacity,1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-25{opacity:.25}.opacity-40{opacity:.4}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-black\/20{--tw-shadow-color:rgba(0,0,0,.2);--tw-shadow:var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:scale-\[1\.02\]:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-\[1\.02\]:hover{--tw-scale-x:1.02;--tw-scale-y:1.02}.hover\:bg-\[\#3a3a3c\]:hover{--tw-bg-opacity:1;background-color:rgb(58 58 60/var(--tw-bg-opacity,1))}.hover\:text-\[\#f5f5f7\]:hover{--tw-text-opacity:1;color:rgb(245 245 247/var(--tw-text-opacity,1))}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:border-\[\#6366f1\]:focus{--tw-border-opacity:1;border-color:rgb(99 102 241/var(--tw-border-opacity,1))}.focus\:shadow-\[0_0_0_3px_rgba\(99\2c 102\2c 241\2c 0\.25\)\]:focus{--tw-shadow:0 0 0 3px rgba(99,102,241,.25);--tw-shadow-colored:0 0 0 3px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}} \ No newline at end of file diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index d28883f6..8c7b93e5 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -1,101 +1,102 @@ - + routatic-proxy Console + - - + -
-
-
-