Skip to content

Commit 1805a8c

Browse files
authored
Introduce opencode-go-anthropic that uses anthropic SDK for qwen and … (#544)
* Introduce opencode-go-anthropic that uses anthropic SDK for qwen and minimax models. * Adjust naming to match convention format. * Adjut naming to match convention. * Update name for opencode-go-anthropic-compatible * Update curated models for opencode anthropic and adjust test. * Adjust filter for opencode anthropic provider and cover with tests. * Adjust discovery model filtering - tolerate ModlIDAllowedForProvider rules. * Change model descriptors to lowercase for minimax models. * CLI call triggers model filtering for opencode-go-anthropic-compatible. * feat: gate runtime model resolution on provider allowlist (PR #544 P2) Apply ModelIDAllowedForProvider(profile.CatalogID, model) at profile resolution and ResolveRuntimeMetadata so TUI /model and headless --model overrides cannot escape the provider-scoped filter for catalog-backed profiles such as opencode-go-anthropic-compatible. resolveProfile is the single funnel for both New() and ResolveRuntimeMetadata, so a single validation call in each return path covers every caller (TUI command_center, exec, provider health, context report, zerocommands). Restricted providers reject; the default branch keeps unrestricted providers unchanged.
1 parent 8b248f4 commit 1805a8c

12 files changed

Lines changed: 400 additions & 5 deletions

File tree

‎internal/cli/provider_models.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99

1010
"github.com/Gitlawb/zero/internal/config"
11+
"github.com/Gitlawb/zero/internal/providermodelcatalog"
1112
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
1213
)
1314

@@ -51,6 +52,19 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps
5152
return writeAppError(stderr, err.Error(), exitProvider)
5253
}
5354

55+
// Apply provider-specific model filtering for catalog-backed profiles.
56+
// For example, opencode-go-anthropic-compatible only permits Qwen and
57+
// MiniMax model IDs; the raw /zen/go/v1/models endpoint returns many more.
58+
if profile.CatalogID != "" {
59+
filtered := make([]providermodeldiscovery.Model, 0, len(models))
60+
for _, model := range models {
61+
if providermodelcatalog.ModelIDAllowedForProvider(profile.CatalogID, model.ID) {
62+
filtered = append(filtered, model)
63+
}
64+
}
65+
models = filtered
66+
}
67+
5468
if options.json {
5569
items := make([]map[string]any, 0, len(models))
5670
for _, model := range models {

‎internal/cli/provider_models_test.go‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,62 @@ func TestRunProvidersModelsUnknownProviderFails(t *testing.T) {
132132
}
133133
}
134134

135+
func TestRunProvidersModelsFiltersForCatalogProfile(t *testing.T) {
136+
var stdout bytes.Buffer
137+
var stderr bytes.Buffer
138+
deps := commandCenterDeps(t)
139+
// Override the resolved config to include a catalog-backed profile.
140+
deps.resolveConfig = func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
141+
profile := config.ProviderProfile{
142+
Name: "opencode-go",
143+
ProviderKind: config.ProviderKindAnthropicCompat,
144+
CatalogID: "opencode-go-anthropic-compatible",
145+
BaseURL: "https://api.example.com/zen/go/v1",
146+
APIKey: "sk-test",
147+
Model: "minimax-max",
148+
}
149+
return config.ResolvedConfig{
150+
ActiveProvider: "opencode-go",
151+
Providers: []config.ProviderProfile{profile},
152+
Provider: profile,
153+
}, nil
154+
}
155+
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
156+
return []providermodeldiscovery.Model{
157+
{ID: "deepseek-chat", Description: "DeepSeek Chat"},
158+
{ID: "qwen-max", Description: "Qwen Max"},
159+
{ID: "minimax-max-01", Description: "MiniMax Max"},
160+
{ID: "kimi-k2", Description: "Kimi K2"},
161+
{ID: "glm-4", Description: "GLM-4"},
162+
}, nil
163+
}
164+
165+
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
166+
167+
if exitCode != exitSuccess {
168+
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
169+
}
170+
out := stdout.String()
171+
// Only Qwen and MiniMax model IDs should survive the filter.
172+
if strings.Contains(out, "deepseek-chat") {
173+
t.Error("output should not contain deepseek-chat (blocked by opencode-go-anthropic filter)")
174+
}
175+
if strings.Contains(out, "kimi-k2") {
176+
t.Error("output should not contain kimi-k2 (blocked by opencode-go-anthropic filter)")
177+
}
178+
if strings.Contains(out, "glm-4") {
179+
t.Error("output should not contain glm-4 (blocked by opencode-go-anthropic filter)")
180+
}
181+
for _, want := range []string{"qwen-max", "minimax-max-01"} {
182+
if !strings.Contains(out, want) {
183+
t.Fatalf("output missing allowed model %q:\n%s", want, out)
184+
}
185+
}
186+
if !strings.Contains(out, "2 models discovered") {
187+
t.Fatalf("output should show 2 models:\n%s", out)
188+
}
189+
}
190+
135191
func TestRunProvidersModelsSurfacesDiscoveryError(t *testing.T) {
136192
var stdout bytes.Buffer
137193
var stderr bytes.Buffer

‎internal/providercatalog/catalog.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ var descriptors = []Descriptor{
140140
openAICompat("kilocode", "KiloCode", "https://api.kilo.ai/api/gateway", "anthropic/claude-sonnet-4.6", []string{"KILO_API_KEY"}, "kilo", "kilo gateway"),
141141
openAICompat("opencode", "OpenCode Zen", "https://opencode.ai/zen/v1", "deepseek-v4-flash", []string{"OPENCODE_API_KEY"}, "opencode zen"),
142142
openAICompat("opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", "deepseek-v4-pro", []string{"OPENCODE_API_KEY"}, "opencode go"),
143+
anthropicCompat("opencode-go-anthropic-compatible", "OpenCode Go Anthropic-compatible", "https://opencode.ai/zen/go", "minimax-m3", []string{"OPENCODE_API_KEY"}),
143144
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
144145
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
145146
// token only works against ChatGPT's own backend (which is Cloudflare-gated to

‎internal/providercatalog/catalog_test.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ var expectedCatalogIDs = []string{
4040
"kilocode",
4141
"opencode",
4242
"opencode-go",
43+
"opencode-go-anthropic-compatible",
4344
"atomic-chat",
4445
"chatgpt-proxy",
4546
"custom-openai-compatible",
@@ -291,7 +292,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
291292
TransportGoogle: {"google"},
292293
TransportBedrock: {"bedrock"},
293294
TransportVertex: {"vertex"},
294-
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "custom-anthropic-compatible"},
295+
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
295296
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
296297
}
297298

‎internal/providermodelcatalog/catalog.go‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,12 @@ var curatedModels = map[string][]Model{
157157
{ID: "gpt-4.1", Description: "catalog default"},
158158
{ID: "gpt-4o-mini", Description: "fast model"},
159159
},
160+
"opencode-go-anthropic-compatible": {
161+
{ID: "minimax-m3", Description: "MiniMax M3: default"},
162+
{ID: "minimax-m2.7", Description: "MiniMax M2.7: coding model"},
163+
{ID: "qwen3.7-plus", Description: "Qwen 3.7 Plus: balanced model"},
164+
{ID: "qwen3.7-max", Description: "Qwen 3.7 Max: strong model"},
165+
},
160166
"custom-openai-compatible": {
161167
{ID: "custom-model", Description: "custom endpoint model"},
162168
},
@@ -167,13 +173,13 @@ var curatedModels = map[string][]Model{
167173

168174
func Models(provider providercatalog.Descriptor) []Model {
169175
if models, ok := curatedModels[provider.ID]; ok {
170-
return dedupeModels(provider.DefaultModel, models)
176+
return FilterModelsForProvider(provider.ID, dedupeModels(provider.DefaultModel, models))
171177
}
172178
models := registryModels(provider)
173179
if len(models) > 0 {
174-
return dedupeModels(provider.DefaultModel, models)
180+
return FilterModelsForProvider(provider.ID, dedupeModels(provider.DefaultModel, models))
175181
}
176-
return dedupeModels(provider.DefaultModel, nil)
182+
return FilterModelsForProvider(provider.ID, dedupeModels(provider.DefaultModel, nil))
177183
}
178184

179185
func registryModels(provider providercatalog.Descriptor) []Model {

‎internal/providermodelcatalog/catalog_test.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ func TestModelsAreProviderScoped(t *testing.T) {
5252
want: []string{"mimo-v2.5-pro", "tencent/hy3"},
5353
notWant: []string{"openai/gpt-4.1", "claude-sonnet-4.5"},
5454
},
55+
{
56+
provider: "opencode-go-anthropic-compatible",
57+
want: []string{"minimax-m3", "minimax-m2.7", "qwen3.7-plus", "qwen3.7-max"},
58+
notWant: []string{"gpt-4.1", "claude-sonnet-4.5", "deepseek-chat"},
59+
},
5560
}
5661

5762
for _, tt := range tests {

‎internal/providermodelcatalog/filter.go‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,38 @@
11
package providermodelcatalog
22

3-
import "strings"
3+
import (
4+
"strings"
5+
6+
"github.com/Gitlawb/zero/internal/providercatalog"
7+
)
8+
9+
// ModelIDAllowedForProvider reports whether a model ID is permitted for the
10+
// given provider under provider-specific allow/block rules.
11+
// For opencode-go-anthropic-compatible: only Qwen and MiniMax model IDs are allowed.
12+
// Allows ANY model by default
13+
func ModelIDAllowedForProvider(providerID, modelID string) bool {
14+
modelID = strings.ToLower(strings.TrimSpace(modelID))
15+
if modelID == "" {
16+
return false
17+
}
18+
switch providercatalog.NormalizeID(providerID) {
19+
case "opencode-go-anthropic-compatible":
20+
return strings.Contains(modelID, "qwen") || strings.Contains(modelID, "minimax")
21+
default:
22+
return true
23+
}
24+
}
25+
26+
// FilterModelsForProvider filters a model slice by ModelIDAllowedForProvider.
27+
func FilterModelsForProvider(providerID string, models []Model) []Model {
28+
result := make([]Model, 0, len(models))
29+
for _, model := range models {
30+
if ModelIDAllowedForProvider(providerID, model.ID) {
31+
result = append(result, model)
32+
}
33+
}
34+
return result
35+
}
436

537
func IsCodingModel(model Model) bool {
638
if IsKnownNonCodingModelID(model.ID) {
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package providermodelcatalog
2+
3+
import (
4+
"testing"
5+
6+
"github.com/Gitlawb/zero/internal/providercatalog"
7+
)
8+
9+
func TestModelIDAllowedForProvider(t *testing.T) {
10+
cases := []struct {
11+
testName string
12+
providerID string
13+
modelID string
14+
want bool
15+
}{
16+
// Empty model ID is always rejected.
17+
{"empty model", "any-provider", "", false},
18+
{"empty model after trim", "any-provider", " ", false},
19+
20+
// opencode-go-anthropic-compatible: only qwen and minimax allowed.
21+
{"opencode-anthropic qwen", "opencode-go-anthropic-compatible", "qwen3.7-plus", true},
22+
{"opencode-anthropic minimax", "opencode-go-anthropic-compatible", "MiniMax-M3", true},
23+
{"opencode-anthropic non-allowed", "opencode-go-anthropic-compatible", "claude-sonnet-4.5", false},
24+
{"opencode-anthropic deepseek", "opencode-go-anthropic-compatible", "deepseek-chat", false},
25+
26+
// Case insensitivity and trimming for the blocked provider.
27+
{"opencode-anthropic case insensitive", "opencode-go-anthropic-compatible", "QWEN3.7-PLUS", true},
28+
{"opencode-anthropic whitespace", "opencode-go-anthropic-compatible", " minimax-m3 ", true},
29+
30+
// Normalized provider ID — e.g. NormalizeID converts spaces/dots to dashes.
31+
{"opencode-anthropic normalized provider", "opencode go anthropic compatible", "qwen3.7-plus", true},
32+
33+
// Default branch: all model IDs allowed.
34+
{"default provider allows anything", "openai", "claude-sonnet-4.5", true},
35+
{"default provider allows any name", "openai", "x", true},
36+
{"default provider groq", "groq", "deepseek-chat", true},
37+
}
38+
39+
for _, tc := range cases {
40+
t.Run(tc.testName, func(t *testing.T) {
41+
got := ModelIDAllowedForProvider(tc.providerID, tc.modelID)
42+
if got != tc.want {
43+
t.Errorf("ModelIDAllowedForProvider(%q, %q) = %v, want %v",
44+
tc.providerID, tc.modelID, got, tc.want)
45+
}
46+
})
47+
}
48+
}
49+
50+
// TestFilterModelsForProvider verifies that filtering delegates correctly.
51+
func TestFilterModelsForProvider(t *testing.T) {
52+
models := []Model{
53+
{ID: "qwen3.7-plus"},
54+
{ID: "MiniMax-M3"},
55+
{ID: "claude-sonnet-4.5"},
56+
{ID: "deepseek-chat"},
57+
}
58+
59+
filtered := FilterModelsForProvider("opencode-go-anthropic-compatible", models)
60+
if len(filtered) != 2 {
61+
t.Fatalf("FilterModelsForProvider returned %d models, want 2: %#v", len(filtered), modelIDs(filtered))
62+
}
63+
if filtered[0].ID != "qwen3.7-plus" || filtered[1].ID != "MiniMax-M3" {
64+
t.Errorf("FilterModelsForProvider returned %#v, want [qwen3.7-plus MiniMax-M3]", modelIDs(filtered))
65+
}
66+
67+
// With a default provider nothing is filtered out.
68+
all := FilterModelsForProvider("openai", models)
69+
if len(all) != len(models) {
70+
t.Fatalf("FilterModelsForProvider(openai) returned %d, want %d", len(all), len(models))
71+
}
72+
73+
// Empty input.
74+
empty := FilterModelsForProvider("opencode-go-anthropic-compatible", nil)
75+
if len(empty) != 0 {
76+
t.Fatal("expected empty result for nil input")
77+
}
78+
}
79+
80+
// TestModelIDAllowedForProvider_NormalizedProviderIDs verifies that various
81+
// raw provider ID formats that NormalizeID maps to the same canonical form
82+
// are handled consistently.
83+
func TestModelIDAllowedForProvider_NormalizedProviderIDs(t *testing.T) {
84+
canonical := providercatalog.NormalizeID("opencode-go-anthropic-compatible")
85+
variants := []string{
86+
"opencode-go-anthropic-compatible",
87+
"opencode_go_anthropic_compatible",
88+
"opencode go anthropic compatible",
89+
"OpenCode-Go-Anthropic-Compatible",
90+
" opencode-go-anthropic-compatible ",
91+
"opencode.go.anthropic.compatible",
92+
}
93+
for _, variant := range variants {
94+
if n := providercatalog.NormalizeID(variant); n != canonical {
95+
t.Fatalf("NormalizeID(%q) = %q, want %q", variant, n, canonical)
96+
}
97+
}
98+
99+
// All variants should produce the same filter result.
100+
for _, variant := range variants {
101+
if !ModelIDAllowedForProvider(variant, "qwen3.7-plus") {
102+
t.Errorf("ModelIDAllowedForProvider(%q, qwen3.7-plus) = false, want true", variant)
103+
}
104+
if ModelIDAllowedForProvider(variant, "claude-sonnet-4.5") {
105+
t.Errorf("ModelIDAllowedForProvider(%q, claude-sonnet-4.5) = true, want false", variant)
106+
}
107+
}
108+
}

‎internal/providermodeldiscovery/discovery.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,9 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
405405
}
406406

407407
func liveModelAllowedWithoutCatalog(provider providercatalog.Descriptor, id string) bool {
408+
if !providermodelcatalog.ModelIDAllowedForProvider(provider.ID, id) {
409+
return false
410+
}
408411
if providermodelcatalog.IsKnownNonCodingModelID(id) {
409412
return false
410413
}

‎internal/providermodeldiscovery/discovery_test.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,47 @@ func TestDiscoverCatalogMergesLiveModelsWithModelsDevMetadata(t *testing.T) {
261261
t.Fatal("missing gpt-4.1")
262262
}
263263

264+
func TestLiveModelAllowedWithoutCatalogChecksProviderGateFirst(t *testing.T) {
265+
// The ModelIDAllowedForProvider check runs before the others.
266+
// For the restricted provider (opencode-go-anthropic-compatible) a
267+
// non-allowed model returns false immediately, without reaching the
268+
// IsKnownNonCodingModelID, Local, or LooksLikeCodingModelID checks.
269+
restricted := providercatalog.Descriptor{
270+
ID: "opencode-go-anthropic-compatible",
271+
Local: true, // would pass the Local check if we got past the gate
272+
}
273+
274+
// A model that isn't qwen/minimax is blocked at the gate, even though
275+
// Local=true would let any model through on its own.
276+
if got := liveModelAllowedWithoutCatalog(restricted, "claude-sonnet-4"); got != false {
277+
t.Fatal("liveModelAllowedWithoutCatalog: want false for claude-sonnet-4 on opencode-go-anthropic-compatible (blocked by ModelIDAllowedForProvider)")
278+
}
279+
280+
// A qwen model passes the gate and continues to the remaining checks;
281+
// it's not a known non-coding model and looks like a coding model, so
282+
// the result is true.
283+
if got := liveModelAllowedWithoutCatalog(restricted, "qwen-max"); got != true {
284+
t.Fatal("liveModelAllowedWithoutCatalog: want true for qwen-max on opencode-go-anthropic-compatible (passes all checks)")
285+
}
286+
287+
// A minimax model also passes the gate.
288+
if got := liveModelAllowedWithoutCatalog(restricted, "minimax-text-01"); got != true {
289+
t.Fatal("liveModelAllowedWithoutCatalog: want true for minimax-text-01 on opencode-go-anthropic-compatible (passes all checks)")
290+
}
291+
292+
// Unrestricted provider: all models pass the gate, so the other checks
293+
// decide the result. claude-sonnet-4 looks like a coding model → true.
294+
openAI := providercatalog.Descriptor{ID: "openai"}
295+
if got := liveModelAllowedWithoutCatalog(openAI, "claude-sonnet-4"); got != true {
296+
t.Fatal("liveModelAllowedWithoutCatalog: want true for claude-sonnet-4 on openai (unrestricted)")
297+
}
298+
299+
// Non-coding model still filtered on an unrestricted provider.
300+
if got := liveModelAllowedWithoutCatalog(openAI, "text-embedding-3-large"); got != false {
301+
t.Fatal("liveModelAllowedWithoutCatalog: want false for embedding model on openai")
302+
}
303+
}
304+
264305
func modelIDs(models []Model) []string {
265306
ids := make([]string, 0, len(models))
266307
for _, model := range models {

0 commit comments

Comments
 (0)