Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,16 @@ make dist # Cross-compile for all platforms

**Purpose:** routatic-proxy is a proxy server that sits between Claude Code 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, not code-driven.** All models are defined in `~/.config/routatic-proxy/config.json` — adding a new model requires no code changes. Go provider models are transformed to OpenAI Chat Completions format automatically. Zen models use endpoint classification via `ClassifyEndpoint()`. The router in `internal/router/` selects models by matching request content against scenario patterns defined in `scenarios.go`.
**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 by the GPT-5.x families (`IsResponsesModel`).
- `EndpointGemini` — Google `/v1/models/{id}`. Used by `gemini-3.5-flash`, `gemini-3.1-pro`, `gemini-3-flash` (`IsGeminiModel`).
- `EndpointResponses` — OpenAI native `/v1/responses`. Used by `gpt-*`, `grok-*`, and `muse-spark-*` models (`IsResponsesModel`).
- `EndpointGemini` — Google `/v1/models/{id}`. Used by `gemini-*` models (`IsGeminiModel`).

Which models take the Anthropic endpoint depends on the provider:

Expand Down Expand Up @@ -81,7 +81,7 @@ Which models take the Anthropic endpoint depends on the provider:
| `qwen3.6-plus` | Go | 1M | 8192 | yes | Streaming fallback |
| `qwen3.5-plus` | Go | 1M | 8192 | yes | Simple read-only ops |

The "typical provider" column reflects how the shipped config wires each model; the registry itself is provider-agnostic, so any model can be pointed at any provider in `config.json`. Zen additionally exposes many models that are not in the registry (Claude, Gemini, GPT-5.x, other free-tier models) — those get their capabilities from the catalog rather than `modelMetadata`.
The "typical provider" column reflects how the shipped config wires each model; the registry itself is provider-agnostic, so any model can be pointed at any provider in `config.json`. Zen additionally exposes many models that are not in the registry (Claude, Gemini, GPT, 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.

Expand Down
69 changes: 33 additions & 36 deletions docs/howto-add-model.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# How to Add a New Model

Adding a new model requires zero code changes. Everything is config-driven.
Adding a model from an existing family requires only config changes. A new Zen
model family that uses a non-default endpoint (Responses, Gemini, or Messages)
may require updating `internal/models/classifier.go`.

## Step 1: Identify the Provider and Endpoint

Expand All @@ -12,56 +14,51 @@ Determine which upstream provider the model uses and which endpoint format it ac
| `opencode-go` | `/v1/messages` | Anthropic Messages (MiniMax, Qwen) |
| `opencode-zen` | `/v1/chat/completions` | OpenAI Chat Completions |
| `opencode-zen` | `/v1/messages` | Anthropic Messages (Claude, Qwen) |
| `opencode-zen` | `/v1/responses` | OpenAI Responses (GPT models) |
| `opencode-zen` | `/v1/responses` | OpenAI Responses (GPT, Grok, Muse Spark) |
| `opencode-zen` | `/v1/models/{id}` | Gemini |
| `aws-bedrock` | `/v1/chat/completions` | OpenAI Chat Completions (Bedrock Mantle) |
| `aws-bedrock` | `/v1/messages` | Anthropic Messages (Bedrock Mantle, requires `wire_format: "anthropic"`) |

## Step 2: Add Endpoint Classification (Zen only)
## Step 2: Check Endpoint Classification

If the model uses Zen, add it to the appropriate classifier in `internal/models/classifier.go`:
Zen endpoint classification is prefix-based. Models in these existing families
need no classifier change:

```go
// For Anthropic endpoint:
func IsZenAnthropicModel(modelID string) bool {
// ...
if modelID == "my-new-model" {
return true
}
// ...
}
| Endpoint | Recognized model prefixes | Classifier |
|----------|---------------------------|------------|
| Anthropic Messages | `claude-*`, `qwen*` | `IsZenAnthropicModel` |
| OpenAI Responses | `gpt-*`, `grok-*`, `muse-spark-*` | `IsResponsesModel` |
| Gemini | `gemini-*` | `IsGeminiModel` |

// For Responses endpoint:
func IsResponsesModel(modelID string) bool {
// ...
case "gpt-5.5", "gpt-5.5-pro", "my-new-model":
return true
// ...
}
Update `internal/models/classifier.go` only when Zen introduces a new model
family that uses a non-default endpoint. Add the family prefix to the
appropriate classifier and add unit-test coverage. For example, a new
Responses family would be added alongside the existing prefixes:

// For Gemini endpoint:
func IsGeminiModel(modelID string) bool {
// ...
case "gemini-3.5-flash", "my-new-model":
return true
// ...
```go
func IsResponsesModel(modelID string) bool {
return strings.HasPrefix(modelID, "gpt-") ||
strings.HasPrefix(modelID, "grok-") ||
strings.HasPrefix(modelID, "muse-spark-") ||
strings.HasPrefix(modelID, "my-responses-family-")
}
```

If the model uses Go provider and requires the Anthropic endpoint (not Chat Completions), add it to `IsAnthropicModel`:
The Go provider is config-driven. If a Go model requires a non-default wire
format, set `wire_format` on its model configuration instead of changing the
Zen classifier:

```go
func IsAnthropicModel(modelID string) bool {
switch modelID {
// ...
case "minimax-m2.5", "my-new-model":
return true
// ...
}
```json
{
"provider": "opencode-go",
"model_id": "my-new-model",
"wire_format": "anthropic"
}
```

**Note:** These classification functions are shared between `internal/client` and `internal/provider` packages to ensure consistent routing.
Supported Go-provider overrides are `openai`, `anthropic`, and `responses`.
Zen classification functions are shared between `internal/client` and
`internal/provider` so both paths route models consistently.

## Step 3: Add to Config

Expand Down
29 changes: 19 additions & 10 deletions internal/client/opencode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,9 @@ func TestClassifyEndpoint(t *testing.T) {
expected: EndpointChatCompletions,
},
{
name: "grok-build-0.1 uses chat completions endpoint",
name: "grok-build-0.1 uses responses endpoint",
modelID: "grok-build-0.1",
expected: EndpointChatCompletions,
expected: EndpointResponses,
},
{
name: "big-pickle uses chat completions endpoint",
Expand Down Expand Up @@ -375,10 +375,13 @@ func TestIsGeminiModel(t *testing.T) {
modelID string
want bool
}{
// Gemini models
// Gemini models (prefix check)
{"gemini-3.5-flash", true},
{"gemini-3.1-pro", true},
{"gemini-3-flash", true},
{"gemini-3.7-flash", true},
{"gemini-3.6-flash", true},
{"gemini-3.5-flash-lite", true},
// Non-Gemini models
{"kimi-k2.6", false},
{"kimi-k2.7-code", false},
Expand Down Expand Up @@ -407,32 +410,38 @@ func TestIsResponsesModel(t *testing.T) {
modelID string
want bool
}{
// GPT 5.5 series
// GPT models (prefix check)
{"gpt-5.5", true},
{"gpt-5.5-pro", true},
{"gpt-5.5-mini", true},
{"gpt-5.5-nano", true},
// GPT 5.4 series
{"gpt-5.4", true},
{"gpt-5.4-pro", true},
{"gpt-5.4-mini", true},
{"gpt-5.4-nano", true},
// GPT 5.3 series
{"gpt-5.3-codex", true},
{"gpt-5.3-codex-spark", true},
// GPT 5.2 series
{"gpt-5.2", true},
{"gpt-5.2-codex", true},
// GPT 5.1 series
{"gpt-5.1", true},
{"gpt-5.1-codex", true},
{"gpt-5.1-codex-max", true},
{"gpt-5.1-codex-mini", true},
// GPT 5 series
{"gpt-5", true},
{"gpt-5-codex", true},
{"gpt-5-nano", true},
// Non-GPT models
{"gpt-5.6-sol", true},
{"gpt-5.6-terra", true},
{"gpt-5.6-luna", true},
// Grok models (prefix check)
{"grok-4.5", true},
{"grok-4.6", true},
{"grok-build-0.1", true},
// Muse Spark models (prefix check)
{"muse-spark-1.2", true},
{"muse-spark-1.2-contributor", true},
{"muse-spark-1.2-contributor-free", true},
// Non-Responses models
{"kimi-k2.6", false},
{"kimi-k2.7-code", false},
{"glm-5.1", false},
Expand Down
22 changes: 6 additions & 16 deletions internal/models/classifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,25 +66,15 @@ func IsZenAnthropicModel(modelID string) bool {
}

// IsGeminiModel returns true for models using the Gemini endpoint.
// Uses prefix check to cover all current and future Gemini models.
func IsGeminiModel(modelID string) bool {
switch modelID {
case "gemini-3.5-flash", "gemini-3.1-pro", "gemini-3-flash":
return true
default:
return false
}
return strings.HasPrefix(modelID, "gemini-")
}

// IsResponsesModel returns true for models using the OpenAI Responses endpoint.
// Uses prefix checks to cover all current and future model families.
func IsResponsesModel(modelID string) bool {
switch modelID {
case "gpt-5.5", "gpt-5.5-pro", "gpt-5.5-mini", "gpt-5.5-nano",
"gpt-5.4", "gpt-5.4-pro", "gpt-5.4-mini", "gpt-5.4-nano",
"gpt-5.3-codex", "gpt-5.3-codex-spark", "gpt-5.2", "gpt-5.2-codex",
"gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-max", "gpt-5.1-codex-mini",
"gpt-5", "gpt-5-codex", "gpt-5-nano":
return true
default:
return false
}
return strings.HasPrefix(modelID, "gpt-") ||
strings.HasPrefix(modelID, "grok-") ||
strings.HasPrefix(modelID, "muse-spark-")
}
3 changes: 2 additions & 1 deletion internal/provider/opencode_zen.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ const upstreamUserAgent = "opencode/routatic-proxy"

// OpenCodeZenProvider implements core.Provider for the OpenCode Zen backend.
// Zen supports four wire formats determined by model ID: Anthropic (Claude,
// Qwen), Responses (GPT), Gemini, and Chat Completions (everything else).
// Qwen), Responses (GPT, Grok, Muse Spark), Gemini, and Chat Completions
// (everything else).
type OpenCodeZenProvider struct {
baseProvider
}
Expand Down
Loading