This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
make build # Build binary to bin/routatic-proxy (CGO disabled by default)
make run # Run without building
make test # Run tests with race detector
make lint # gofmt check + go vet (does NOT run tests)
make lint-strict # golangci-lint run with .golangci.yml (requires golangci-lint 2.x)
make clean # Remove build artifacts
make install # Build and install to $GOPATH/bin
make dist # Cross-compile for all platforms
# Start proxy with dashboard (recommended)
./bin/routatic-proxy start
# Start proxy only (headless)
./bin/routatic-proxy serveroutatic-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
routatic-proxy serve runs headless (no dashboard).
Purpose: routatic-proxy is a proxy server that sits between Codex and OpenCode Go. It intercepts Anthropic API requests, transforms them to OpenAI Chat Completions format, forwards them to OpenCode Go, and transforms responses back to Anthropic SSE.
Model routing is config-driven for existing model families. All models are defined in ~/.config/routatic-proxy/config.json. Adding a Go-provider model or a Zen model whose ID matches a recognized family prefix requires only config changes. A new Zen family that uses a non-default endpoint requires updating ClassifyEndpoint(). Go-provider wire-format differences remain configurable through wire_format. The router in internal/router/ selects models by matching request content against scenario patterns defined in scenarios.go.
If a model's upstream doesn't support Anthropic tool format (type: "custom" server-tool shorthands), set "anthropic_tools_disabled": true in the model config to force it through the Chat Completions transform path instead of the raw Anthropic endpoint.
Four endpoint types (EndpointType, internal/models/classifier.go):
EndpointChatCompletions— OpenAI-compatible/v1/chat/completions. The default, and what most models use.EndpointAnthropic— Anthropic/v1/messages.EndpointResponses— OpenAI native/v1/responses. Used bygpt-*,grok-*, andmuse-spark-*models (IsResponsesModel).EndpointGemini— Google/v1/models/{id}. Used bygemini-*models (IsGeminiModel).
Which models take the Anthropic endpoint depends on the provider:
- Go provider —
IsAnthropicModel(classifier.go) returns true forminimax-m2.5,minimax-m2.7,minimax-m3andqwen3.5-plus,qwen3.6-plus,qwen3.7-plus,qwen3.7-max. Everything else goes through the Chat Completions transform. - Zen provider —
ClassifyEndpointis Zen-specific.IsZenAnthropicModelroutes anyCodex-*orqwen*model to Anthropic; MiniMax on Zen uses Chat Completions (unlike MiniMax on the Go provider).
Wire format overrides. A model config's wire_format field overrides the built-in classification on the Go provider only — "openai" (aliases chat, chat_completions), "anthropic" (alias messages), or "responses". This is how a Go model reaches the OpenAI Responses endpoint (opencode_go.responses_base_url), since Go classification never selects Responses on its own. "gemini", "auto", empty, and unrecognised values all fall back to classification — the Go provider has no Gemini path. Zen and Bedrock ignore the per-model override and classify by model ID.
core.ParseWireFormat is the only place wire_format strings are interpreted, and Provider.WireFormat(config.ModelConfig) is the only place a model's format is resolved. Execute, Stream, and the streaming handler in internal/handlers/messages.go all dispatch on that one method so the endpoint a request is sent to and the SSE parser used to read the reply cannot disagree. Do not re-derive the format at a call site.
Available models: the built-in capability registry is modelMetadata in internal/config/model_registry.go. It supplies context window, max output tokens, and vision support whenever the runtime config omits them (ResolveModelConfig). Every entry has SupportsTools: true.
| Model ID | Typical provider | Context | Max output | Vision | Best For |
|---|---|---|---|---|---|
deepseek-v4-pro |
Go | 1M | 8192 | no | Default + complex scenarios in the shipped config |
deepseek-v4-flash |
Go | 1M | 4096 | no | Background / fast scenarios |
deepseek-v4-flash-free |
Zen | 1M | 4096 | no | Free-tier fallback |
glm-5.2 |
Go | 200K | 8192 | no | Think scenario, architecture decisions |
glm-5.1 |
Go | 200K | 8192 | no | Complex patterns, tool operations |
glm-5 |
Go | 200K | 8192 | no | Reasoning tasks (deprecated May 14, 2026) |
kimi-k3 |
Go | 1M | 131072 | yes | Flagship Kimi, huge output budget, multimodal |
kimi-k2.7-code |
Go | 256K | 32768 | yes | Large code generation |
kimi-k2.6 |
Go | 256K | 8192 | yes | General purpose, common fallback |
kimi-k2.5 |
Go | 256K | 8192 | yes | Previous-generation Kimi fallback |
minimax-m3 |
Go | 1M | 128000 | no | Long-context scenario in the shipped config |
minimax-m2.7 |
Go | 200K | 8192 | no | Previous MiniMax generation |
minimax-m2.5 |
Go | 200K | 4096 | no | Older MiniMax generation |
mimo-v2.5-pro |
Go | 1M | 16384 | no | Step-by-step reasoning, larger output |
mimo-v2.5 |
Go | 1M | 8192 | no | Step-by-step reasoning |
mimo-v2.5-free |
Zen | 1M | 8192 | no | Free-tier fallback |
mimo-v2-omni |
Go | 1M | 8192 | yes | Multimodal MiMo |
qwen3.7-max |
Go | 1M | 8192 | yes | Complex coding, Qwen's best quality |
qwen3.7-plus |
Go | 1M | 8192 | yes | Streaming, low-latency |
qwen3.6-plus |
Go | 1M | 8192 | yes | Streaming fallback |
qwen3.5-plus |
Go | 1M | 8192 | yes | Simple read-only ops |
The "typical provider" column reflects how the shipped config wires each model; the registry itself is provider-agnostic, so any model can be pointed at any provider in config.json. Zen additionally exposes many models that are not in the registry (Codex, Gemini, GPT, Grok, Muse Spark, and other free-tier models) — those get their capabilities from the catalog rather than modelMetadata.
internal/client/opencode.go routes Go provider models to Chat Completions; Zen models are classified by models.ClassifyEndpoint() in internal/models/classifier.go. If a model's upstream doesn't support Anthropic tool format, set anthropic_tools_disabled: true in config.
Scenario detection priority (DetectScenario, internal/router/scenarios.go). Models below are the built-in defaults from cmd/routatic-proxy/templates/default_config.json, which is what routatic-proxy init writes:
- Long context — token count > threshold (
getLongContextThreshold, default 100K, configurable via thelong_contextmodel'scontext_threshold) →minimax-m3. If the latest user message also carries an image, the scenario isvision_long_contextinstead. - Vision — the latest user message contains an image. Splits by intent:
vision_complexwhen the text also shows complex intent, otherwisevision. - Complex — architectural patterns or tool-heavy operations →
deepseek-v4-pro. - Think — reasoning keywords →
glm-5.2. - Background — simple read-only ops with no tools →
deepseek-v4-flash. - Default →
deepseek-v4-pro.
The three vision scenarios are ScenarioVision, ScenarioVisionComplex, and ScenarioVisionLongContext (scenarios.go). The shipped default config has no vision* model entries, so vision requests fall through to the ordinary scenario models unless you add them.
The Reason strings in scenarios.go describe only why a scenario matched and name no model. The resolved model is appended by ModelRouter.Route / RouteForStreaming (describeRouting), so the routing log line always reports the model that actually came from config — e.g. scenario=complex (complex or tool-based operation keywords in latest user message) -> resolved model glm-5.2. A test asserts detector reasons never name a model, so they cannot drift again.
Model overrides: two config blocks bypass scenario routing based on the requested model. model_overrides matches the model string exactly (best with CC-Switch, which sends a custom model string). model_family_overrides maps a Codex family keyword (opus, sonnet, haiku) via case-insensitive substring match, so the versioned IDs Codex sends natively (Codex-opus-4-20250514) route without CC-Switch. Precedence: exact model_overrides → model_family_overrides (longest key first) → respect_requested_model → scenario routing. Both are wired through ModelRouter.RouteWithOverride / RouteWithFamilyOverride (internal/router/model_router.go) and merged with a deduplicated scenario safety-net chain in buildModelChain (internal/handlers/messages.go). Override entries accept any provider models and fallbacks accept — opencode-go, opencode-zen, aws-bedrock, openrouter (underscore spellings normalized) — validated against config.KnownProviders in internal/config/provider.go, which is the single source for provider names and NormalizeProvider; client.Provider* are aliases of those constants.
Cost-based routing: when cost_routing.enabled is set, Selector in internal/router/selector.go replaces the static primary model with automatic cheapest-model selection from the catalog. It applies max_context_window (hard cap on context window), prefer_providers (global provider filter, intersected with per-scenario preferences), and penalty_per_provider (per-provider cost penalty added during sort). Enabled via cost_routing.enabled or the legacy enable_cost_based_routing flag.
Catalog schema: Models are keyed as provider/model-name (e.g., opencode-go/glm-5.2). The catalog (~/.config/routatic-proxy/catalog/catalog.json) contains:
providers— Provider definitions withname,base_url,enabledmodels— Model definitions keyed by full key with fields:id— Full key (matches the map key)name— Display namelimit.context— Context window sizerates.input/rates.output— Cost per million tokenstool_call— Whether tools are supportedmodalities.input/output— Input/output types (["text"]or["text", "image"]for vision)reasoning— Whether reasoning mode is supported
Resolution functions in internal/catalog/resolve.go extract the provider from the key prefix. ResolvedModel.ModelID is the model name only (without provider prefix); ResolvedModel.CanonicalName is the full key.
For streaming, RouteForStreaming downgrades complex/think requests to the fast scenario for better TTFT (deepseek-v4-flash in the shipped default config).
Deprecated models:
- GLM-5 — deprecated May 14, 2026; use GLM-5.1 or GLM-5.2
Polymorphic field handling: Anthropic's system and content fields accept both strings and arrays. pkg/types/ uses json.RawMessage with accessor methods (SystemText(), ContentBlocks()) to handle both formats.
Long-running stream policy: The proxy never kills a stream that is actively producing bytes. The server-level WriteTimeout is set to 0; instead each upstream read uses a per-Read deadline via http.ResponseController.SetReadDeadline that is renewed on every successful byte. If the gap between bytes exceeds OpenCodeGo.stream_timeout_ms (or OpenCodeZen.stream_timeout_ms), the connection is treated as stuck and the request is routed to the next fallback model. Defaults to timeout_ms when unset. Client disconnects during a stream are logged at Debug level — this is normal during Codex tool execution and is not a failure signal.
Provider-specific API keys: Each provider (OpenCode Go, OpenCode Zen, AWS Bedrock, OpenRouter) can have its own api_key or api_keys array. Provider-specific keys take precedence over global keys. This enables per-provider fallback strategies and key rotation.
Environment variable overrides (single key):
ROUTATIC_PROXY_OPENCODE_GO_API_KEYROUTATIC_PROXY_OPENCODE_ZEN_API_KEYROUTATIC_PROXY_AWS_BEDROCK_API_KEYROUTATIC_PROXY_OPENROUTER_API_KEY
Environment variable overrides (comma-separated keys for round-robin):
ROUTATIC_PROXY_OPENCODE_GO_API_KEYS=key-1,key-2,key-3ROUTATIC_PROXY_OPENCODE_ZEN_API_KEYS=key-1,key-2ROUTATIC_PROXY_AWS_BEDROCK_API_KEYS=key-1,key-2ROUTATIC_PROXY_OPENROUTER_API_KEYS=key-1,key-2
Precedence: *_API_KEYS → *_API_KEY → global API_KEYS → global API_KEY.
cmd/routatic-proxy/main.go— CLI entry point (cobra). Default config template is generated here.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).internal/handlers/models.go—GET /v1/models(OpenAI-style listing). Used by provider-switching tools like CC-Switch's "Fetch Models" button; sources IDs fromModelRouter.ListModels(config aliases +model_overrideskeys + catalog canonical names).configs/config.example.json— Reference config with all options documented.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.
The Settings tab exposes all config fields as editable form inputs. On save, only changed fields are sent to the backend as a JSON patch. The backend reads the current config from disk, merges the patch, writes back, and reloads atomically — the running proxy picks up changes immediately without restart.
Partial update flow:
- Frontend builds a patch object with only fields the user changed (compared to the last loaded config)
- Backend reads current config from disk via
config.LoadFromPath() - Backend merges patch fields onto current config via JSON marshal/unmarshal
- Backend validates essential fields (host, port)
- Backend writes merged config to disk and calls
atomicCfg.Reload()
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.
This project uses a dual release channel system for separating beta and production releases:
- Trigger: Every push to
mainbranch (see.github/workflows/beta-release.yml) - Version format:
v{UPCOMING}-beta.{N}(e.g.,v0.6.4-beta.1), where{N}is a sequential counter - GitHub release: Marked as
prerelease: true - Docker tags:
v{UPCOMING}-beta.{N},beta-{PROD}(the latest stable version, e.g.beta-v0.6.3), andbeta(rolling pointer to newest beta)
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
- Trigger: Manual
workflow_dispatchonreleasesbranch (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_PATsecret) - Scoop bucket update (requires
SCOOP_PATsecret)
.github/scripts/get-versions.sh is used by the beta workflow to:
- Fetch tags from the
origin/releasesbranch to get current production version (e.g.,v0.6.3) - Increment the patch to the next version (e.g.,
v0.6.4) - beta is based on the upcoming patch release - Generate beta version by appending
-beta.{N}, where{N}ismax(existing beta counters for this upcoming version) + 1- the counter resets to 1 once the upcoming version ships as stable - Output both versions as JSON for CI consumption
Version Format Explanation:
v0.6.4= The upcoming production version (patch incremented from latest production)beta.1= Sequential prerelease counter for that upcoming version- Full example: stable
v0.6.3→v0.6.4-beta.1, thenv0.6.4-beta.2, ... untilv0.6.4ships →v0.6.5-beta.1
- Merge all changes to
mainand verify via beta - Ensure
releasesbranch exists and is up-to-date - Go to GitHub Actions → Release workflow
- Click "Run workflow"
- Enter version (must follow
vX.Y.Zformat) - Workflow validates, builds, and releases
Both workflows share the same stages:
- validate — Run
go vet,go test -race, and build sanity check on ubuntu-latest - rpm — Build and verify the Fedora RPMs on ubuntu-latest, then pass them to
releaseas therpm-packagesartifact (.github/scripts/build-rpms.shandverify-rpm.sh) - release — Build cross-platform binaries and macOS DMG on macos-latest, and publish every asset — binaries, DMG, RPMs, checksums — through one atomic
gh release create - docker — Publish multi-arch Docker images on ubuntu-latest
Production adds: 5. homebrew — Update the homebrew-tap formula 6. scoop — Update the scoop-bucket manifest
The RPMs are packaged in their own Linux job rather than in release for two reasons: rpm/rpm2cpio are unavailable on the macOS runner, so verification has to happen on Linux; and this repo publishes immutable releases, so assets cannot be added after gh release create — everything must be present for that single call.
When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
Key routing rules:
- Product ideas/brainstorming → invoke /office-hours
- Strategy/scope → invoke /plan-ceo-review
- Architecture → invoke /plan-eng-review
- Design system/plan review → invoke /design-consultation or /plan-design-review
- Full review pipeline → invoke /autoplan
- Bugs/errors → invoke /investigate
- QA/testing site behavior → invoke /qa or /qa-only
- Code review/diff check → invoke /review
- Visual polish → invoke /design-review
- Ship/deploy/PR → invoke /ship or /land-and-deploy
- Save progress → invoke /context-save
- Resume context → invoke /context-restore
- Author a backlog-ready spec/issue → invoke /spec