From 58cc63230a15d1b3b63daee4d9715c54bcd3681e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B6rje=20Granberg?= Date: Thu, 21 May 2026 13:44:22 +0200 Subject: [PATCH] feat: support min_coding_score via model-ID postfix for Pareto router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds postfix syntax on Pareto model IDs so the quality tier can be set per-role in config without any new config fields: openrouter/pareto-code:high → min_coding_score 0.9 openrouter/pareto-code:medium → 0.7 openrouter/pareto-code:low → 0.5 openrouter/pareto-code:0.85 → exact numeric value The proxy strips the suffix from the model field and injects min_coding_score into the request body before forwarding to OpenRouter. :nitro and unrecognized suffixes are left untouched. Only applies to the /v1/messages path (Anthropic format); the reverse- proxy path for OpenAI-format calls is not affected. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 23 ++++++++ proxy/proxy.go | 74 +++++++++++++++++++++++++ proxy/proxy_test.go | 130 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+) diff --git a/README.md b/README.md index 8f99a83..d3581b6 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,29 @@ models: You can swap in any model available on OpenRouter — not just Anthropic ones. +## Pareto routing + +OpenRouter's [Pareto router](https://openrouter.ai/openrouter/pareto-code) picks the cheapest model that meets a minimum coding quality threshold (`min_coding_score`, 0.0–1.0). Use a postfix on the model ID to set the threshold: + +```yaml +models: + opus: "openrouter/pareto-code:high" # score 0.9 — best coding models + sonnet: "openrouter/pareto-code:medium" # score 0.7 — balanced + haiku: "openrouter/pareto-code:low" # score 0.5 — budget + subagent: "openrouter/pareto-code:0.85" # exact numeric value +``` + +| Postfix | `min_coding_score` | +|---|---| +| `:high` | 0.9 | +| `:medium` / `:mid` | 0.7 | +| `:low` | 0.5 | +| `:0.0`–`:1.0` | as specified | +| `:nitro` | none (OpenRouter speed variant — unchanged) | +| _(none)_ | none (OpenRouter dashboard default) | + +The postfix is stripped from the model ID before the request reaches OpenRouter. + ## How it works `orcc claude` replaces itself (via `exec`) with the `claude` process after setting: diff --git a/proxy/proxy.go b/proxy/proxy.go index a981160..fff704a 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -8,6 +8,7 @@ import ( "io" "log" "net/http" + "strconv" "strings" "sync" "time" @@ -105,6 +106,14 @@ func (p *Proxy) handleMessages(w http.ResponseWriter, r *http.Request) { log.Printf("handleMessages: injected openrouter:web_search") } + if scoreBody, scoreChanged, err := extractParetoScore(modified); err != nil { + log.Printf("extractParetoScore: %v", err) + } else if scoreChanged { + modified = scoreBody + changed = true + log.Printf("handleMessages: injected min_coding_score for Pareto model") + } + msgURL := p.target + "/messages" if r.URL.RawQuery != "" { msgURL += "?" + r.URL.RawQuery @@ -668,6 +677,71 @@ func (p *Proxy) ensureModels() []map[string]string { return list } +// ── Pareto router score injection ───────────────────────────────────────────── + +// paretoScoreSuffixes maps named tier aliases to min_coding_score values. +var paretoScoreSuffixes = map[string]float64{ + "low": 0.5, + "mid": 0.7, + "medium": 0.7, + "high": 0.9, +} + +// extractParetoScore inspects the request body's "model" field. If the model +// starts with "openrouter/pareto" and ends with a recognized score suffix +// (numeric 0.0–1.0 or a named alias), the suffix is stripped from the model +// field and min_coding_score is injected via the pareto-router plugin: +// +// "plugins": [{"id": "pareto-router", "min_coding_score": 0.8}] +// +// The ":nitro" suffix is left intact and does not trigger score injection. +// Other suffixes are left untouched. +func extractParetoScore(body []byte) ([]byte, bool, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return body, false, nil + } + + model := jsonStr(raw["model"]) + if !strings.HasPrefix(model, "openrouter/pareto") { + return body, false, nil + } + + lastColon := strings.LastIndex(model, ":") + if lastColon < 0 { + return body, false, nil + } + + base := model[:lastColon] + suffix := model[lastColon+1:] + + var score float64 + if s, ok := paretoScoreSuffixes[suffix]; ok { + score = s + } else if f, err := strconv.ParseFloat(suffix, 64); err == nil && f >= 0.0 && f <= 1.0 { + score = f + } else { + // Unknown suffix (including :nitro) — pass through unchanged. + return body, false, nil + } + + raw["model"] = mustMarshal(base) + + plugin := map[string]interface{}{ + "id": "pareto-router", + "min_coding_score": score, + } + var plugins []json.RawMessage + if pluginsRaw, ok := raw["plugins"]; ok { + json.Unmarshal(pluginsRaw, &plugins) + } + plugins = append(plugins, mustMarshal(plugin)) + raw["plugins"] = mustMarshal(plugins) + + out, _ := json.Marshal(raw) + return out, true, nil +} + func mustMarshal(v any) []byte { data, err := json.Marshal(v) if err != nil { diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go index acbb056..b23848e 100644 --- a/proxy/proxy_test.go +++ b/proxy/proxy_test.go @@ -114,6 +114,136 @@ func TestInjectWebSearch(t *testing.T) { } } +func TestExtractParetoScore(t *testing.T) { + tests := []struct { + name string + input string + wantModel string + wantScore float64 + wantSet bool + }{ + { + name: "numeric score", + input: `{"model":"openrouter/pareto-code:0.8","messages":[]}`, + wantModel: "openrouter/pareto-code", + wantScore: 0.8, + wantSet: true, + }, + { + name: "high alias", + input: `{"model":"openrouter/pareto-code:high","messages":[]}`, + wantModel: "openrouter/pareto-code", + wantScore: 0.9, + wantSet: true, + }, + { + name: "low alias", + input: `{"model":"openrouter/pareto-code:low","messages":[]}`, + wantModel: "openrouter/pareto-code", + wantScore: 0.5, + wantSet: true, + }, + { + name: "mid alias", + input: `{"model":"openrouter/pareto-code:mid","messages":[]}`, + wantModel: "openrouter/pareto-code", + wantScore: 0.7, + wantSet: true, + }, + { + name: "medium alias", + input: `{"model":"openrouter/pareto-code:medium","messages":[]}`, + wantModel: "openrouter/pareto-code", + wantScore: 0.7, + wantSet: true, + }, + { + name: "no suffix", + input: `{"model":"openrouter/pareto-code","messages":[]}`, + wantSet: false, + }, + { + name: "nitro suffix untouched", + input: `{"model":"openrouter/pareto-code:nitro","messages":[]}`, + wantSet: false, + }, + { + name: "non-pareto model untouched", + input: `{"model":"anthropic/claude-sonnet-4.6","messages":[]}`, + wantSet: false, + }, + { + name: "unknown suffix untouched", + input: `{"model":"openrouter/pareto-code:wat","messages":[]}`, + wantSet: false, + }, + { + name: "out-of-range numeric untouched", + input: `{"model":"openrouter/pareto-code:1.5","messages":[]}`, + wantSet: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out, changed, err := extractParetoScore([]byte(tc.input)) + if err != nil { + t.Fatalf("extractParetoScore: %v", err) + } + + if !tc.wantSet { + if changed { + t.Errorf("expected unchanged, got changed") + } + return + } + + if !changed { + t.Fatalf("expected changed, got unchanged") + } + + var got map[string]json.RawMessage + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("parse output: %v", err) + } + + gotModel := jsonStr(got["model"]) + if gotModel != tc.wantModel { + t.Errorf("model: got %q, want %q", gotModel, tc.wantModel) + } + + var plugins []map[string]json.RawMessage + if err := json.Unmarshal(got["plugins"], &plugins); err != nil || len(plugins) == 0 { + t.Fatalf("plugins missing or unparseable") + } + p := plugins[len(plugins)-1] + if id := jsonStr(p["id"]); id != "pareto-router" { + t.Errorf("plugin id: got %q, want %q", id, "pareto-router") + } + var gotScore float64 + if err := json.Unmarshal(p["min_coding_score"], &gotScore); err != nil { + t.Fatalf("parse min_coding_score from plugin: %v", err) + } + if gotScore != tc.wantScore { + t.Errorf("min_coding_score: got %v, want %v", gotScore, tc.wantScore) + } + }) + } +} + +func TestExtractParetoScoreInvalidJSON(t *testing.T) { + out, changed, err := extractParetoScore([]byte(`{invalid`)) + if err != nil { + t.Fatalf("expected no error on invalid JSON, got: %v", err) + } + if changed { + t.Errorf("expected changed=false on invalid JSON") + } + if string(out) != "{invalid" { + t.Errorf("expected original body returned, got: %s", string(out)) + } +} + func TestInjectWebSearchInvalidJSON(t *testing.T) { // Invalid JSON returns original body unchanged (no error) out, changed, err := injectWebSearch([]byte(`{invalid`))