Skip to content

Commit dce29ff

Browse files
authored
fix(opencodego): reasoning fallback + simplify OpenCode Go catalog (#33)
* refactor: remove opencodego client and legacy model tier configs Drop the OpenCode Go provider and hawk ModelKey tier tables in favor of live catalog data and deployment routing. * feat(opencodego): OpenAI-compatible client with live model discovery Reuse chat/completions and GET /models like other gateways: thin OpenCodeGoClient wrapper, dynamic catalog fetch, and filter out models that require the messages API (MiniMax, Qwen3.x). * feat(opencodego): dual-protocol routing with shared DualProtocolPair Route MiniMax/Qwen via /v1/messages with chat/completions fallback, discover all models live, and centralize OpenAI/Anthropic switching for OpenCode Go and MiMo. * refactor(client): rename DualProtocolPair to ProtocolRouter Reuse OpenAIClient and AnthropicClient directly for chat-first OpenCode Go models; keep ProtocolRouter for shared fallback orchestration only. * fix(opencodego): recover reasoning-only MiniMax streams via chat fallback When /v1/messages streams thinking without answer text, retry through non-streaming chat/completions before streaming fallback so MiniMax M3 returns usable replies on OpenCode Go. * fix(ci): satisfy lint after main merge Remove redundant nil checks in ProtocolRouter and delete unused tier helpers. * fix(ci): remove unused contains helper
1 parent 12d7fea commit dce29ff

38 files changed

Lines changed: 942 additions & 634 deletions

catalog/catalog_test.go

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,27 @@ func TestGetModelMarketingName(t *testing.T) {
3535
}
3636
}
3737

38-
func TestGetProviderModelCandidates_LiveSetupProvidersEmpty(t *testing.T) {
39-
if got := GetProviderModelCandidates("anthropic", TierSonnet); len(got) != 0 {
40-
t.Fatalf("anthropic tier candidates should be empty, got %v", got)
38+
func TestGetProviderModelCandidates_AllProvidersNil(t *testing.T) {
39+
// All providers return nil candidates (fully dynamic)
40+
allProviders := []string{
41+
"anthropic", "openai", "gemini", "grok", "bedrock", "kimi",
42+
}
43+
for _, provider := range allProviders {
44+
if got := GetProviderModelCandidates(provider, TierSonnet); got != nil {
45+
t.Fatalf("%s tier candidates should be nil, got %v", provider, got)
46+
}
4147
}
4248
}
4349

44-
func TestGetProviderDefaultModel_LiveSetupProvidersEmptyWithoutCatalog(t *testing.T) {
45-
if got := GetProviderDefaultModel("anthropic", nil); got != "" {
46-
t.Fatalf("anthropic default should be empty without catalog, got %q", got)
50+
func TestGetProviderDefaultModel_AllProvidersEmptyWithoutCatalog(t *testing.T) {
51+
// All providers return empty without a catalog (fully dynamic)
52+
allProviders := []string{
53+
"anthropic", "openai", "gemini", "grok", "bedrock", "kimi",
4754
}
48-
if got := GetProviderDefaultModel("openai", nil); got != "" {
49-
t.Fatalf("openai default should be empty without catalog, got %q", got)
55+
for _, provider := range allProviders {
56+
if got := GetProviderDefaultModel(provider, nil); got != "" {
57+
t.Fatalf("%s default should be empty without catalog, got %q", provider, got)
58+
}
5059
}
5160
}
5261

@@ -76,17 +85,3 @@ func TestModelsForProvider(t *testing.T) {
7685
t.Error("expected no models for nil catalog")
7786
}
7887
}
79-
80-
func TestCanonicalModelIDs(t *testing.T) {
81-
ids := CanonicalModelIDs()
82-
if len(ids) != len(AllModelConfigs) {
83-
t.Errorf("expected %d canonical IDs, got %d", len(AllModelConfigs), len(ids))
84-
}
85-
}
86-
87-
func TestCanonicalIDToKey(t *testing.T) {
88-
m := CanonicalIDToKey()
89-
if _, ok := m["claude-sonnet-4-6"]; !ok {
90-
t.Error("expected claude-sonnet-4-6 in canonical ID to key map")
91-
}
92-
}

catalog/live/fetchers.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"strings"
1414
"time"
1515

16+
"github.com/GrayCodeAI/eyrie/catalog/opencodego"
1617
"github.com/GrayCodeAI/eyrie/catalog/xiaomi"
1718
)
1819

@@ -24,7 +25,7 @@ const (
2425
DefaultZAIBaseURL = "https://api.z.ai/api/paas/v4"
2526
DefaultOpenAIBaseURL = "https://api.openai.com/v1"
2627
DefaultGrokBaseURL = "https://api.x.ai/v1"
27-
DefaultOpenCodeGoBaseURL = "https://opencode.ai/zen/go/v1"
28+
DefaultOpenCodeGoBaseURL = opencodego.DefaultBaseURL
2829
DefaultKimiBaseURL = "https://api.moonshot.ai/v1"
2930
DefaultXiaomiBaseURL = "https://api.xiaomimimo.com/v1"
3031
)
@@ -489,11 +490,18 @@ func FetchCanopyWave(env map[string]string) ([]Entry, error) {
489490
}
490491

491492
func FetchOpenCodeGo(env map[string]string) ([]Entry, error) {
492-
return fetchOpenAICompatModels(
493+
entries, err := fetchOpenAICompatModels(
493494
context.Background(),
494495
envOr(env, "OPENCODEGO_BASE_URL", DefaultOpenCodeGoBaseURL),
495496
env["OPENCODEGO_API_KEY"], "Bearer",
496497
)
498+
if err != nil {
499+
return nil, err
500+
}
501+
for i := range entries {
502+
entries[i].ID = opencodego.NativeModelID(entries[i].ID)
503+
}
504+
return entries, nil
497505
}
498506

499507
func FetchKimi(env map[string]string) ([]Entry, error) {

catalog/live/opencodego_test.go

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ func TestFetchOpenCodeGo_MockHTTPServer(t *testing.T) {
2121
Data []json.RawMessage `json:"data"`
2222
}{
2323
Data: []json.RawMessage{
24-
json.RawMessage(`{"id":"ocg/model-1","display_name":"OCG Model 1","status":1}`),
24+
json.RawMessage(`{"id":"kimi-k2.6","owned_by":"opencode"}`),
25+
json.RawMessage(`{"id":"minimax-m2.7","owned_by":"opencode"}`),
26+
json.RawMessage(`{"id":"qwen3.7-max","owned_by":"opencode"}`),
2527
},
2628
}
2729
_ = json.NewEncoder(w).Encode(resp)
@@ -35,15 +37,12 @@ func TestFetchOpenCodeGo_MockHTTPServer(t *testing.T) {
3537
if err != nil {
3638
t.Fatal(err)
3739
}
38-
if len(entries) != 1 {
39-
t.Fatalf("expected 1 model, got %d", len(entries))
40+
if len(entries) != 3 {
41+
t.Fatalf("expected all 3 models, got %d", len(entries))
4042
}
41-
if entries[0].ID != "ocg/model-1" {
43+
if entries[0].ID != "kimi-k2.6" {
4244
t.Fatalf("id = %q", entries[0].ID)
4345
}
44-
if len(entries[0].RawJSON) == 0 {
45-
t.Fatal("expected RawJSON to be preserved")
46-
}
4746
}
4847

4948
func TestFetchOpenCodeGo_NoKey(t *testing.T) {

catalog/live_catalog.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import (
1010
// LiveCatalogStaleDuration is how long a cache remains fresh after live provider APIs were merged.
1111
const LiveCatalogStaleDuration = 24 * time.Hour
1212

13-
// IsLiveOnlyProvider reports setup providers that list models from live APIs only (not static tiers).
13+
// IsLiveOnlyProvider reports whether a provider uses live API discovery only.
14+
// All providers are now fully dynamic.
1415
func IsLiveOnlyProvider(providerID string) bool {
15-
_, ok := registry.SpecByProviderID(normalizeLiveProviderID(providerID))
16-
return ok
16+
return true
1717
}
1818

1919
// DeploymentIDForLiveCatalogKey maps a live fetch catalog key to a deployment ID.

catalog/live_catalog_test.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ import (
77
)
88

99
func TestIsLiveOnlyProvider(t *testing.T) {
10-
if !catalog.IsLiveOnlyProvider("canopywave") {
11-
t.Fatal("canopywave should be live-only")
10+
// All providers are now fully dynamic
11+
allProviders := []string{
12+
"anthropic", "openai", "gemini", "grok", "canopywave", "z-ai", "openrouter", "ollama", "opencodego",
13+
"azure", "bedrock", "vertex", "kimi", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", "deepseek",
1214
}
13-
if !catalog.IsLiveOnlyProvider("z-ai") {
14-
t.Fatal("z-ai should be live-only")
15-
}
16-
if !catalog.IsLiveOnlyProvider("anthropic") {
17-
t.Fatal("anthropic should be live-only")
15+
for _, p := range allProviders {
16+
if !catalog.IsLiveOnlyProvider(p) {
17+
t.Fatalf("%s should be live-only (all providers are fully dynamic)", p)
18+
}
1819
}
1920
}
2021

@@ -30,8 +31,14 @@ func TestFirstModelForProvider(t *testing.T) {
3031
}
3132
}
3233

33-
func TestGetProviderDefaultModel_LiveOnlySkipsHardcoded(t *testing.T) {
34-
if got := catalog.GetProviderDefaultModel("canopywave", &catalog.ModelCatalog{}); got != "" {
35-
t.Fatalf("expected empty default without catalog, got %q", got)
34+
func TestGetProviderDefaultModel_AllProvidersEmptyWithoutCatalog(t *testing.T) {
35+
// All providers return empty without a catalog (fully dynamic)
36+
allProviders := []string{
37+
"anthropic", "openai", "gemini", "grok", "bedrock", "kimi",
38+
}
39+
for _, p := range allProviders {
40+
if got := catalog.GetProviderDefaultModel(p, &catalog.ModelCatalog{}); got != "" {
41+
t.Fatalf("%s: expected empty default without catalog, got %q", p, got)
42+
}
3643
}
3744
}

0 commit comments

Comments
 (0)