diff --git a/AGENTS.md b/AGENTS.md index f28ce4f5..c5f164bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,10 @@ When starting any new work (feature, fix, refactor, chore), always create a feat ## Design Principles -- **Model-agnostic** — single interface for 75+ LLM providers +- **Model-agnostic** — single interface for 23 registered LLM providers +- **Plan-accurate provider metadata** — Concentrate AI is pay-as-you-go, uses + `concentrate-payg` as its deployment identifier, and uses its native Responses + API (`/v1/responses`) without legacy Chat Completions or Messages routing - **Host-neutral engine** — Eyrie owns provider routing, transport, caching, retry/fallback, and normalized telemetry; hosts own product UX and semantics - **Streaming-first** — all responses are streamed; blocking is opt-in diff --git a/catalog/concentrate/concentrate.go b/catalog/concentrate/concentrate.go index 269e7dca..be428785 100644 --- a/catalog/concentrate/concentrate.go +++ b/catalog/concentrate/concentrate.go @@ -1,90 +1,6 @@ -// Package concentrate holds shared constants and helpers for the Concentrate AI gateway -// (https://concentrate.ai). Models are discovered via GET /v1/models; chat -// routing picks /v1/chat/completions or /v1/messages per model based on owned_by. +// Package concentrate holds shared constants and pricing-cache helpers for the +// Concentrate AI Responses API gateway (https://concentrate.ai). package concentrate -import ( - "strings" - "sync" -) - // DefaultBaseURL is the Concentrate AI API root. const DefaultBaseURL = "https://api.concentrate.ai/v1" - -// protocolFromOwner maps Concentrate's owned_by field to a protocol. -// Anthropic models use the Messages API; everything else uses Chat Completions. -func protocolFromOwner(ownedBy string) string { - switch strings.ToLower(strings.TrimSpace(ownedBy)) { - case "anthropic": - return "anthropic" - default: - return "openai" - } -} - -// Dynamic protocol map — populated from live fetch results. -var ( - protocolMapMu sync.RWMutex - protocolMap = map[string]string{} // model ID → "anthropic" | "openai" - protocolMapValid = false -) - -// UpdateProtocolMap refreshes the dynamic protocol map from live fetch entries. -// Called after every successful FetchConcentrate in the discover pipeline. -func UpdateProtocolMap(entries []struct{ ID, Protocol string }) { - protocolMapMu.Lock() - defer protocolMapMu.Unlock() - for _, e := range entries { - id := strings.ToLower(e.ID) - if id == "" || e.Protocol == "" { - continue - } - protocolMap[id] = e.Protocol - } - protocolMapValid = true -} - -// UsesMessagesAPI reports whether a model should use Anthropic /v1/messages on -// Concentrate AI. -// -// Resolution order: -// 1. Dynamic protocol map (populated from live /v1/models response) -// 2. Heuristic fallback (model name pattern matching for Claude models) -func UsesMessagesAPI(modelID string) bool { - id := strings.ToLower(modelID) - if id == "" { - return false - } - - // Check dynamic map (from live fetch). - protocolMapMu.RLock() - if protocolMapValid { - if proto, ok := protocolMap[id]; ok { - protocolMapMu.RUnlock() - return proto == "anthropic" - } - } - protocolMapMu.RUnlock() - - // Fallback to heuristic: Claude models use Messages API. - return strings.Contains(id, "claude") -} - -// ProtocolMapSnapshot returns a copy of the current protocol map for testing/debugging. -func ProtocolMapSnapshot() map[string]string { - protocolMapMu.RLock() - defer protocolMapMu.RUnlock() - out := make(map[string]string, len(protocolMap)) - for k, v := range protocolMap { - out[k] = v - } - return out -} - -// ResetProtocolMap clears the dynamic protocol map. For testing only. -func ResetProtocolMap() { - protocolMapMu.Lock() - defer protocolMapMu.Unlock() - protocolMap = map[string]string{} - protocolMapValid = false -} diff --git a/catalog/concentrate/concentrate_test.go b/catalog/concentrate/concentrate_test.go deleted file mode 100644 index 010003fe..00000000 --- a/catalog/concentrate/concentrate_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package concentrate - -import ( - "testing" -) - -func TestProtocolFromOwner(t *testing.T) { - t.Parallel() - tests := []struct { - ownedBy string - want string - }{ - {"anthropic", "anthropic"}, - {"Anthropic", "anthropic"}, - {"ANTHROPIC", "anthropic"}, - {"openai", "openai"}, - {"google", "openai"}, - {"mistral", "openai"}, - {"", "openai"}, - } - for _, tt := range tests { - got := protocolFromOwner(tt.ownedBy) - if got != tt.want { - t.Errorf("protocolFromOwner(%q) = %q, want %q", tt.ownedBy, got, tt.want) - } - } -} - -func TestUpdateProtocolMap(t *testing.T) { - t.Parallel() - ResetProtocolMap() - t.Cleanup(ResetProtocolMap) - - entries := []struct{ ID, Protocol string }{ - {"claude-opus-5", "anthropic"}, - {"gpt-5", "openai"}, - {"claude-sonnet-4", "anthropic"}, - } - UpdateProtocolMap(entries) - - if got := ProtocolMapSnapshot(); len(got) != 3 { - t.Errorf("protocol map len = %d, want 3", len(got)) - } - if !UsesMessagesAPI("claude-opus-5") { - t.Error("claude-opus-5 should use Messages API") - } - if UsesMessagesAPI("gpt-5") { - t.Error("gpt-5 should NOT use Messages API") - } - if !UsesMessagesAPI("claude-sonnet-4") { - t.Error("claude-sonnet-4 should use Messages API") - } -} - -func TestUsesMessagesAPI_NoLiveData(t *testing.T) { - t.Parallel() - ResetProtocolMap() - t.Cleanup(ResetProtocolMap) - - // Without live data, Claude models still use Messages API via heuristic - if !UsesMessagesAPI("claude-opus-5") { - t.Error("without live data, Claude models should use Messages API via heuristic") - } - // Non-Claude models don't use Messages API without live data - if UsesMessagesAPI("gpt-5") { - t.Error("without live data, GPT models should NOT use Messages API") - } -} - -func TestProtocolMapSnapshot_Isolation(t *testing.T) { - t.Parallel() - ResetProtocolMap() - t.Cleanup(ResetProtocolMap) - - UpdateProtocolMap([]struct{ ID, Protocol string }{{"claude-opus-5", "anthropic"}}) - snap := ProtocolMapSnapshot() - snap["gpt-5"] = "anthropic" // mutating snapshot shouldn't affect map - - if UsesMessagesAPI("gpt-5") { - t.Error("mutating snapshot should not affect internal map") - } -} - -func TestResetProtocolMap(t *testing.T) { - t.Parallel() - // Map a non-Claude model to anthropic protocol - UpdateProtocolMap([]struct{ ID, Protocol string }{{"some-model", "anthropic"}}) - if !UsesMessagesAPI("some-model") { - t.Fatal("expected some-model to use Messages API before reset") - } - ResetProtocolMap() - if UsesMessagesAPI("some-model") { - t.Error("after reset, some-model should NOT use Messages API") - } - // Claude models still use Messages API via heuristic even after reset - if !UsesMessagesAPI("claude-opus-5") { - t.Error("after reset, Claude models should still use Messages API via heuristic") - } -} diff --git a/catalog/default_catalog_test.go b/catalog/default_catalog_test.go index 8734dbe1..96879739 100644 --- a/catalog/default_catalog_test.go +++ b/catalog/default_catalog_test.go @@ -19,7 +19,7 @@ func TestSeedCatalog_ReturnsCatalog(t *testing.T) { func TestSeedCatalog_HasProviders(t *testing.T) { t.Parallel() c := SeedCatalog() - expected := []string{"anthropic", "openai", "google", "xai", "openrouter", "ollama", "opencodego", "canopywave"} + expected := []string{"anthropic", "openai", "google", "xai", "openrouter", "concentrate", "ollama", "opencodego", "canopywave"} for _, id := range expected { if c.Providers[id].ID == "" { t.Errorf("missing provider %q in default catalog", id) @@ -32,7 +32,7 @@ func TestSeedCatalog_HasDeployments(t *testing.T) { c := SeedCatalog() expected := []string{ "anthropic-direct", "openai-direct", - "grok-direct", "openrouter", "ollama-local", + "grok-direct", "openrouter", "concentrate-payg", "ollama-local", "opencodego", "canopywave", } for _, id := range expected { @@ -42,6 +42,24 @@ func TestSeedCatalog_HasDeployments(t *testing.T) { } } +func TestSeedCatalog_ConcentrateIsPayAsYouGo(t *testing.T) { + t.Parallel() + c := SeedCatalog() + + if got := c.Providers["concentrate"].Name; got != "Concentrate AI (Pay-as-you-go)" { + t.Fatalf("Concentrate provider name = %q", got) + } + if got := c.Deployments["concentrate-payg"].Name; got != "Concentrate AI (Pay-as-you-go)" { + t.Fatalf("Concentrate deployment name = %q", got) + } + if got := c.Deployments["concentrate-payg"].APIProtocolID; got != "openai-responses" { + t.Fatalf("Concentrate protocol = %q", got) + } + if got := c.Deployments["concentrate-payg"].AdapterConstructor; got != "concentrate-responses" { + t.Fatalf("Concentrate adapter = %q", got) + } +} + func TestSeedCatalog_HasModels(t *testing.T) { t.Parallel() c := SeedCatalog() diff --git a/catalog/discover/discover.go b/catalog/discover/discover.go index 78484583..3bc3af64 100644 --- a/catalog/discover/discover.go +++ b/catalog/discover/discover.go @@ -124,6 +124,7 @@ func run(ctx context.Context, opts Options) (*catalog.RefreshResult, error) { } } + catalog.PruneUnreferencedDeployments(base) if err := catalog.WriteCatalogCache(opts.CachePath, base); err != nil { return nil, fmt.Errorf("catalog discover: write cache: %w", err) } diff --git a/catalog/discover/provider_refresh.go b/catalog/discover/provider_refresh.go index 11480188..5070e03c 100644 --- a/catalog/discover/provider_refresh.go +++ b/catalog/discover/provider_refresh.go @@ -2,8 +2,8 @@ package discover import ( "context" - "encoding/json" "fmt" + "strings" "time" "github.com/GrayCodeAI/eyrie/catalog" @@ -97,8 +97,8 @@ func refreshProvider(ctx context.Context, providerID string, opts ProviderRefres ID: depID, Name: provID, ProviderID: provID, - APIProtocolID: "openai-chat-completions", - AdapterConstructor: "openai", + APIProtocolID: spec.ProtocolID, + AdapterConstructor: spec.AdapterID, NativeModelIDSource: catalog.NativeModelIDDiscovered, ModelMappingsRequired: false, } @@ -114,13 +114,8 @@ func refreshProvider(ctx context.Context, providerID string, opts ProviderRefres name = entryID } canonicalID := entryID - var liveMeta map[string]interface{} - if json.Unmarshal(entry.RawJSON, &liveMeta) == nil { - if inputPrice, ok := liveMeta["input_token_price_per_m"]; ok { - if _, ok := inputPrice.(float64); ok { - canonicalID = provID + "/" + entryID - } - } + if !strings.Contains(entryID, "/") { + canonicalID = provID + "/" + entryID } cat.Models[canonicalID] = catalog.Model{ ID: canonicalID, @@ -142,11 +137,15 @@ func refreshProvider(ctx context.Context, providerID string, opts ProviderRefres }) } + // Replace the selected deployment's topology as well as its offerings so a + // stale cache cannot retain an older adapter or protocol definition. + delete(base.Deployments, depID) base = MergeCatalogWithPolicy(base, &cat, MergePolicy{ PreferLive: true, PreferLiveProviders: []string{catalog.CanonicalProviderID(spec.ProviderID)}, ReplaceDeploymentOfferings: []string{spec.DeploymentID}, }) + catalog.PruneUnreferencedDeployments(base) now := time.Now().UTC().Truncate(time.Second) base.GeneratedAt = now base.StaleAfter = now.Add(catalog.LiveStaleDuration) diff --git a/catalog/discover/provider_refresh_test.go b/catalog/discover/provider_refresh_test.go index 2711581e..4522b616 100644 --- a/catalog/discover/provider_refresh_test.go +++ b/catalog/discover/provider_refresh_test.go @@ -47,3 +47,50 @@ func TestRefreshProvider_MergesLiveModelsIntoCache(t *testing.T) { t.Fatal("expected live metadata on cached offering") } } + +func TestRefreshProvider_QualifiesUnpricedConcentrateModelIDs(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/models" { + t.Errorf("request path = %q, want /models", r.URL.Path) + } + _, _ = w.Write([]byte(`{ + "data": [{ + "id": "claude-fable-5", + "display_name": "Claude Fable 5", + "owned_by": "anthropic", + "max_input_tokens": 200000, + "max_tokens": 8192 + }] + }`)) + })) + defer srv.Close() + + cachePath := filepath.Join(t.TempDir(), "model_catalog.json") + base := catalog.SeedCatalog() + if err := catalog.WriteCatalogCache(cachePath, &base); err != nil { + t.Fatal(err) + } + + result, err := discover.RefreshProviderWithOptions(context.Background(), "concentrate", discover.ProviderRefreshOptions{ + Credentials: catalog.Credentials{APIKeys: map[string]string{ + "CONCENTRATE_API_KEY": "test-key-12345678", + "CONCENTRATE_BASE_URL": srv.URL, + }}, + CachePath: cachePath, + DisableCredentialFallback: true, + }) + if err != nil { + t.Fatal(err) + } + if result == nil || result.Compiled == nil { + t.Fatal("expected compiled catalog") + } + const canonicalID = "concentrate/claude-fable-5" + if _, ok := result.Compiled.ModelsByID[canonicalID]; !ok { + t.Fatalf("compiled catalog is missing canonical model %q", canonicalID) + } + if got, ok := catalog.CanonicalModelForProviderNative(result.Compiled, "concentrate", "claude-fable-5"); !ok || got != canonicalID { + t.Fatalf("native model mapping = %q, %v; want %q, true", got, ok, canonicalID) + } +} diff --git a/catalog/live/agnes_test.go b/catalog/live/agnes_test.go new file mode 100644 index 00000000..d37caa75 --- /dev/null +++ b/catalog/live/agnes_test.go @@ -0,0 +1,120 @@ +package live + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestFetchAgnes_Mock(t *testing.T) { + // Mock Agnes /v1/models endpoint (OpenAI-compatible format) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Errorf("expected /v1/models, got %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "object": "list", + "data": [ + { + "id": "agnes-2.0-flash", + "object": "model", + "created": 1700000000, + "owned_by": "agnes" + }, + { + "id": "agnes-2.0-pro", + "object": "model", + "created": 1700000000, + "owned_by": "agnes" + } + ] + }`)) + })) + defer server.Close() + + entries, err := FetchAgnes(map[string]string{ + "AGNES_API_KEY": "test-key", + "AGNES_BASE_URL": server.URL + "/v1", + }) + if err != nil { + t.Fatalf("FetchAgnes: %v", err) + } + + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + + // Verify model IDs + if entries[0].ID != "agnes-2.0-flash" { + t.Errorf("expected agnes-2.0-flash, got %s", entries[0].ID) + } + if entries[1].ID != "agnes-2.0-pro" { + t.Errorf("expected agnes-2.0-pro, got %s", entries[1].ID) + } + + flash := entries[0] + if flash.ContextWindow != 512_000 || flash.MaxOutput != 65_536 { + t.Fatalf("agnes-2.0-flash context/max = %d/%d", flash.ContextWindow, flash.MaxOutput) + } + if !flash.ImageInput || !flash.ThinkingEnabled { + t.Fatalf("agnes-2.0-flash caps image=%v thinking=%v", flash.ImageInput, flash.ThinkingEnabled) + } + if flash.InputPricePer1M != 0 || flash.OutputPricePer1M != 0 { + t.Fatalf("agnes-2.0-flash should be free (0), got in=%f out=%f", flash.InputPricePer1M, flash.OutputPricePer1M) + } + foundTools := false + for _, f := range flash.Features { + if f == "tools" { + foundTools = true + } + } + if !foundTools { + t.Fatalf("agnes-2.0-flash missing tools feature: %v", flash.Features) + } + + // Unknown models keep unknown pricing; flash enrichment overrides to free. + for _, e := range entries { + if e.ID == "agnes-2.0-pro" && (e.InputPricePer1M >= 0 || e.OutputPricePer1M >= 0) { + t.Errorf("expected unknown pricing for %s, got in=%f out=%f", e.ID, e.InputPricePer1M, e.OutputPricePer1M) + } + } +} + +func TestFetchAgnes_DefaultBaseURL(t *testing.T) { + // Verify default base URL is used when AGNES_BASE_URL is not set + // We can't easily test the actual call without a mock, but we can + // verify the function doesn't panic with empty base URL + // (it will fail trying to connect to the default URL) + _, err := FetchAgnes(map[string]string{ + "AGNES_API_KEY": "test-key", + }) + // We expect a connection error since we're not running a real server + if err == nil { + t.Log("expected connection error, got nil (unexpected but not a test failure)") + } +} + +func TestFetchAgnes_Unauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"message":"无效的令牌"}}`)) + })) + defer server.Close() + + _, err := FetchAgnes(map[string]string{ + "AGNES_API_KEY": "invalid-key", + "AGNES_BASE_URL": server.URL, + }) + if err == nil { + t.Fatal("expected error for unauthorized request") + } +} diff --git a/catalog/live/concentrate_test.go b/catalog/live/concentrate_test.go new file mode 100644 index 00000000..5953a7e0 --- /dev/null +++ b/catalog/live/concentrate_test.go @@ -0,0 +1,33 @@ +package live + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestFetchConcentrate_PreservesFunctionCallingCapability(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/models" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"deepseek-v4-pro","display_name":"DeepSeek V4 Pro","owned_by":"deepseek","max_input_tokens":1040000,"max_tokens":384000,"capabilities":{"structured_outputs":{"supported":true}}}]}`)) + })) + defer server.Close() + + entries, err := FetchConcentrate(map[string]string{"CONCENTRATE_BASE_URL": server.URL}) + if err != nil { + t.Fatalf("FetchConcentrate: %v", err) + } + if len(entries) != 1 { + t.Fatalf("entries = %d, want 1", len(entries)) + } + if !strings.Contains(strings.Join(entries[0].Features, ","), "function_calling") { + t.Fatalf("features = %v, want function_calling", entries[0].Features) + } +} diff --git a/catalog/live/fetchers.go b/catalog/live/fetchers.go index 95e1e0f0..a61de7d1 100644 --- a/catalog/live/fetchers.go +++ b/catalog/live/fetchers.go @@ -71,6 +71,8 @@ var Registry = map[string]FetchFunc{ "zai_payg": FetchZAI, "zai_coding": FetchZAICoding, "concentrate": FetchConcentrate, + "agnes": FetchAgnes, + "longcat": FetchLongCat, "canopywave": FetchCanopyWave, "opencodego": FetchOpenCodeGo, "kimi": FetchKimi, @@ -102,6 +104,7 @@ type listModelJSON struct { Description string `json:"description"` ContextLength *int `json:"context_length"` ContextSize *int `json:"context_size"` + ContextWindow *int `json:"context_window"` // LongCat and similar OpenAI-compat APIs MaxCompletionTokens *int `json:"max_completion_tokens"` MaxOutputTokens *int `json:"max_output_tokens"` MaxInputTokens *int `json:"max_input_tokens"` @@ -256,7 +259,7 @@ func entryFromOpenAICompatJSON(raw json.RawMessage) (Entry, bool) { InputPricePer1M: inPrice, OutputPricePer1M: outPrice, CachedReadPricePer1M: cachedRead, CachedWritePricePer1M: cachedWrite, - ContextWindow: intOrFirst(0, m.ContextLength, m.ContextSize, m.MaxInputTokens), + ContextWindow: intOrFirst(0, m.ContextLength, m.ContextSize, m.ContextWindow, m.MaxInputTokens), MaxOutput: intOrFirst(0, m.MaxCompletionTokens, m.MaxOutputTokens, m.MaxTokens), Features: features, Protocol: protocol, diff --git a/catalog/live/fetchers_providers.go b/catalog/live/fetchers_providers.go index c30aa943..22a6580a 100644 --- a/catalog/live/fetchers_providers.go +++ b/catalog/live/fetchers_providers.go @@ -590,21 +590,137 @@ func FetchPoolside(env map[string]string) ([]Entry, error) { ) } -// FetchConcentrate lists models from the Concentrate AI OpenAI-compatible API. +// FetchAgnes lists models from the Agnes AI OpenAI-compatible API. +// Agnes exposes no per-token pricing in its /models response (and the Pro +// model uses a pre-deduction balance hold rather than a simple per-token +// charge), so the price is marked unknown rather than assumed free. A negative +// sentinel is the PricingFromEntry convention for PricingUnknown. +// +// Official text-model limits/capabilities (OpenAI chat completions only): +// https://wiki.agnes-ai.com/en/docs/agnes-20-flash.md +// https://wiki.agnes-ai.com/en/docs/agnes-25-flash.md +// https://wiki.agnes-ai.com/en/docs/agnes-25-pro-alpha.md +func FetchAgnes(env map[string]string) ([]Entry, error) { + entries, err := fetchOpenAICompatModels( + context.Background(), + envOr(env, "AGNES_BASE_URL", "https://apihub.agnes-ai.com/v1"), + env["AGNES_API_KEY"], "Bearer", + ) + if err != nil { + return nil, err + } + for i := range entries { + entries[i].InputPricePer1M = -1 + entries[i].OutputPricePer1M = -1 + entries[i] = enrichAgnesEntry(entries[i]) + } + return entries, nil +} + +// Official Agnes text-model reference when /models omits context/capabilities. +// Image/video models use separate generation endpoints and are left as-is. +// Pricing from wiki docs (current promotional $0 for flash; pro-alpha is paid). +var agnesOfficialTextSpecs = map[string]struct { + ContextWindow, MaxOutput int + Tools, Vision, Thinking bool + InputPrice, OutputPrice float64 // per 1M tokens; negative = unknown +}{ + "agnes-1.5-flash": {256_000, 65_536, true, true, false, 0, 0}, + "agnes-2.0-flash": {512_000, 65_536, true, true, true, 0, 0}, + "agnes-2.5-flash": {512_000, 65_536, true, true, true, 0, 0}, + "agnes-2.5-pro-alpha": {1_048_576, 65_536, true, true, true, 0.45, 0.90}, +} + +func enrichAgnesEntry(e Entry) Entry { + id := strings.TrimSpace(e.ID) + if i := strings.LastIndex(id, "/"); i >= 0 { + id = id[i+1:] + } + spec, ok := agnesOfficialTextSpecs[id] + if !ok { + return e + } + if e.ContextWindow <= 0 { + e.ContextWindow = spec.ContextWindow + } + if e.MaxOutput <= 0 { + e.MaxOutput = spec.MaxOutput + } + if spec.Tools { + e.Features = appendUnique(e.Features, "tools") + } + if spec.Vision { + e.ImageInput = true + e.Features = appendUnique(e.Features, "image_input") + } + if spec.Thinking { + e.ThinkingEnabled = true + e.Features = appendUnique(e.Features, "thinking:enabled") + } + if spec.InputPrice >= 0 && spec.OutputPrice >= 0 { + e.InputPricePer1M = spec.InputPrice + e.OutputPricePer1M = spec.OutputPrice + } + return e +} + +// FetchLongCat lists models from the LongCat OpenAI-compatible API only +// (https://api.longcat.chat/openai/v1). Official docs: +// https://longcat.chat/platform/docs/api/chat — text-only chat, tools, and +// thinking={"type":"enabled"|"disabled"}; pricing is not in /models. +func FetchLongCat(env map[string]string) ([]Entry, error) { + entries, err := fetchOpenAICompatModels( + context.Background(), + envOr(env, "LONGCAT_BASE_URL", "https://api.longcat.chat/openai/v1"), + env["LONGCAT_API_KEY"], "Bearer", + ) + if err != nil { + return nil, err + } + for i := range entries { + entries[i].InputPricePer1M = -1 + entries[i].OutputPricePer1M = -1 + entries[i] = enrichLongCatEntry(entries[i]) + } + return entries, nil +} + +const ( + longCatDefaultContext = 1_048_576 + longCatDefaultMaxOut = 131_072 +) + +func enrichLongCatEntry(e Entry) Entry { + if e.ContextWindow <= 0 { + e.ContextWindow = longCatDefaultContext + } + if e.MaxOutput <= 0 { + e.MaxOutput = longCatDefaultMaxOut + } + e.Features = appendUnique(e.Features, "tools") + e.ThinkingEnabled = true + e.Features = appendUnique(e.Features, "thinking:enabled") + e.ImageInput = false // official chat API is text-only + return e +} + +// FetchConcentrate lists models from the public Concentrate AI model catalog. // Concentrate returns a rich format with max_input_tokens, max_tokens, and capabilities. -// Set CONCENTRATE_FETCH_PRICING=true to fetch pricing from individual model endpoints -// (slower but more accurate). Pricing is cached to disk for 24 hours. +// Pricing is fetched from individual model endpoints and cached to disk for 24 hours. +// Set CONCENTRATE_FETCH_PRICING=false to skip pricing fetch (faster but shows "free"). // // Note: /v1/models does not require authentication per Concentrate docs. -// CONCENTRATE_API_KEY is only needed when CONCENTRATE_FETCH_PRICING=true. +// CONCENTRATE_API_KEY is needed for pricing fetch from individual model endpoints. func FetchConcentrate(env map[string]string) ([]Entry, error) { apiKey := strings.TrimSpace(env["CONCENTRATE_API_KEY"]) isCustomBaseURL := env["CONCENTRATE_BASE_URL"] != "" - fetchPricing := strings.EqualFold(env["CONCENTRATE_FETCH_PRICING"], "true") + // Default: fetch pricing unless explicitly disabled + fetchPricing := !strings.EqualFold(env["CONCENTRATE_FETCH_PRICING"], "false") // Pricing fetch requires auth; model listing does not if fetchPricing && apiKey == "" { - return nil, fmt.Errorf("concentrate: CONCENTRATE_API_KEY required when CONCENTRATE_FETCH_PRICING=true") + // Fall back to no pricing rather than erroring out + fetchPricing = false } baseURL := strings.TrimRight(envOr(env, "CONCENTRATE_BASE_URL", "https://api.concentrate.ai/v1"), "/") @@ -687,12 +803,6 @@ func FetchConcentrate(env map[string]string) ([]Entry, error) { OwnedBy: strings.TrimSpace(m.OwnedBy), RawJSON: append(json.RawMessage(nil), raw...), } - // Set protocol based on owned_by (anthropic → Messages API, others → Chat Completions) - if strings.EqualFold(m.OwnedBy, "anthropic") { - entry.Protocol = "anthropic" - } else { - entry.Protocol = "openai" - } // Extract capabilities entry.ThinkingEnabled = m.Capabilities.Thinking.Types.Enabled.Supported entry.ThinkingAdaptive = m.Capabilities.Thinking.Types.Adaptive.Supported @@ -771,14 +881,14 @@ func FetchConcentrate(env map[string]string) ([]Entry, error) { if entry.ImageInput { entry.Features = append(entry.Features, "image_input") } + // Concentrate's current list response exposes general model + // capabilities but omits the provider-level supports.tools field. + // The Responses API and model-details endpoint advertise function + // calling for these routed models, so preserve that capability for + // Hawk's tool-enabled coding loop. + entry.Features = append(entry.Features, "function_calling") entries = append(entries, entry) } - // Update protocol map for dual-protocol routing (anthropic → Messages API, others → Chat Completions). - protocolEntries := make([]struct{ ID, Protocol string }, 0, len(entries)) - for i := range entries { - protocolEntries = append(protocolEntries, struct{ ID, Protocol string }{ID: entries[i].ID, Protocol: entries[i].Protocol}) - } - concentrate.UpdateProtocolMap(protocolEntries) // Fetch precise pricing from individual model endpoints if requested (slower but accurate). // Pricing is cached to disk for 24 hours to avoid repeated API calls. if fetchPricing && !isCustomBaseURL { diff --git a/catalog/live/longcat_test.go b/catalog/live/longcat_test.go new file mode 100644 index 00000000..a0f67ec9 --- /dev/null +++ b/catalog/live/longcat_test.go @@ -0,0 +1,81 @@ +package live + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestEntryFromOpenAICompatJSON_ParsesLongCatContextWindow(t *testing.T) { + t.Parallel() + raw := json.RawMessage(`{ + "id": "LongCat-2.0", + "object": "model", + "owned_by": "LongCat", + "display_name": "LongCat-2.0", + "context_window": 1048576, + "max_output_tokens": 131072 + }`) + entry, ok := entryFromOpenAICompatJSON(raw) + if !ok { + t.Fatal("expected parse ok") + } + if entry.ID != "LongCat-2.0" { + t.Fatalf("id = %q", entry.ID) + } + if entry.ContextWindow != 1_048_576 || entry.MaxOutput != 131_072 { + t.Fatalf("context/max = %d/%d", entry.ContextWindow, entry.MaxOutput) + } +} + +func TestFetchLongCat_Mock(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/models" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "object": "list", + "data": []map[string]any{{ + "id": "LongCat-2.0", + "object": "model", + "owned_by": "LongCat", + "display_name": "LongCat-2.0", + "context_window": 1048576, + "max_output_tokens": 131072, + }}, + }) + })) + defer srv.Close() + + entries, err := FetchLongCat(map[string]string{ + "LONGCAT_API_KEY": "test-key-12345678", + "LONGCAT_BASE_URL": srv.URL, + }) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].ContextWindow != 1_048_576 || entries[0].MaxOutput != 131_072 { + t.Fatalf("context/max = %d/%d", entries[0].ContextWindow, entries[0].MaxOutput) + } + if entries[0].InputPricePer1M >= 0 || entries[0].OutputPricePer1M >= 0 { + t.Fatalf("expected unknown pricing, got in=%f out=%f", entries[0].InputPricePer1M, entries[0].OutputPricePer1M) + } + if !entries[0].ThinkingEnabled || entries[0].ImageInput { + t.Fatalf("longcat caps thinking=%v image=%v", entries[0].ThinkingEnabled, entries[0].ImageInput) + } + foundTools := false + for _, f := range entries[0].Features { + if f == "tools" { + foundTools = true + } + } + if !foundTools { + t.Fatalf("missing tools feature: %v", entries[0].Features) + } +} diff --git a/catalog/live_enrich.go b/catalog/live_enrich.go index 519855c3..2bf0c27e 100644 --- a/catalog/live_enrich.go +++ b/catalog/live_enrich.go @@ -56,8 +56,8 @@ func FetchLiveProviderCatalog(env map[string]string) (Catalog, []LiveProviderEnr ID: deploymentID, Name: providerID, ProviderID: providerID, - APIProtocolID: "openai-chat-completions", - AdapterConstructor: "openai", + APIProtocolID: spec.ProtocolID, + AdapterConstructor: spec.AdapterID, NativeModelIDSource: NativeModelIDDiscovered, ModelMappingsRequired: false, } @@ -83,7 +83,7 @@ func FetchLiveProviderCatalog(env map[string]string) (Catalog, []LiveProviderEnr } else if hasInputPricing(entry.RawJSON) { canonicalID = providerID + "/" + entryID } - } else if hasInputPricing(entry.RawJSON) { + } else { canonicalID = providerID + "/" + entryID } @@ -123,7 +123,7 @@ func FetchLiveModelEntriesForProvider(env map[string]string, providerID string) return nil, fmt.Errorf("catalog: provider %q has no live model list API", providerID) } env = registry.ScopedProviderEnv(spec, env) - if !registry.CredentialPresent(spec, env) { + if !spec.PublicModelCatalog && !registry.CredentialPresent(spec, env) { return nil, fmt.Errorf("catalog: set %s for %s", spec.CredentialEnv, providerID) } entries, err := live.Fetch(spec.LiveFetcherKey, env) diff --git a/catalog/live_enrich_test.go b/catalog/live_enrich_test.go index f58d2263..f4e83a1f 100644 --- a/catalog/live_enrich_test.go +++ b/catalog/live_enrich_test.go @@ -1,6 +1,9 @@ package catalog_test import ( + "fmt" + "net/http" + "net/http/httptest" "strings" "testing" @@ -8,6 +11,35 @@ import ( "github.com/GrayCodeAI/eyrie/catalog/registry" ) +func TestFetchLiveModelEntriesForProvider_ConcentratePublicCatalogNeedsNoKey(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/models" { + t.Errorf("path = %q, want /models", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "" { + t.Errorf("Authorization = %q, want empty", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"data":[{"id":"anthropic/claude-test","display_name":"Claude Test","owned_by":"anthropic","max_input_tokens":200000,"max_tokens":8192}]}`) + })) + t.Cleanup(server.Close) + + entries, err := catalog.FetchLiveModelEntriesForProvider(map[string]string{ + "CONCENTRATE_BASE_URL": server.URL, + }, "concentrate") + if err != nil { + t.Fatalf("FetchLiveModelEntriesForProvider: %v", err) + } + if len(entries) != 1 { + t.Fatalf("entries = %d, want 1", len(entries)) + } + if got := entries[0].ID; got != "anthropic/claude-test" { + t.Fatalf("id = %q", got) + } +} + func TestFetchLiveProviderCatalog_SkipsProvidersWithoutCredentials(t *testing.T) { t.Parallel() cat, enrichment := catalog.FetchLiveProviderCatalog(map[string]string{}) diff --git a/catalog/provider_live_parity_test.go b/catalog/provider_live_parity_test.go index 00e4ce50..603d72fd 100644 --- a/catalog/provider_live_parity_test.go +++ b/catalog/provider_live_parity_test.go @@ -11,8 +11,8 @@ import ( func TestAllProviders_LiveFetchParity(t *testing.T) { t.Parallel() specs := registry.All() - if len(specs) != 23 { - t.Fatalf("expected 23 providers, got %d", len(specs)) + if len(specs) != 25 { + t.Fatalf("expected 25 providers, got %d", len(specs)) } for _, spec := range specs { t.Run(spec.ProviderID, func(t *testing.T) { diff --git a/catalog/registry/protocol_matrix_test.go b/catalog/registry/protocol_matrix_test.go new file mode 100644 index 00000000..eb604d1f --- /dev/null +++ b/catalog/registry/protocol_matrix_test.go @@ -0,0 +1,87 @@ +package registry_test + +import ( + "testing" + + "github.com/GrayCodeAI/eyrie/catalog/registry" +) + +// Official protocol surfaces (validated against provider docs, Jul 2026): +// +// OpenAI-only (Anthropic not needed / not advertised): +// agnes, kimi, openai, grok, openrouter, groq, canopywave, poolside, +// clinepass, ollama, azure, gemini (native/Gemini protocol), concentrate (Responses) +// +// Vendors documenting both OpenAI + Anthropic (hawk uses exactly one — OpenAI): +// deepseek, zai_*, xiaomi_mimo_*, minimax_*, longcat +// opencodego keeps both clients only for per-model routing (one protocol per call; +// never cross-protocol fallback on the same request) +// +// Anthropic-primary: +// anthropic, bedrock +// +// Rule: if a vendor is OpenAI-compatible only, do not invent an Anthropic client. +// If a vendor documents both, hawk uses OpenAI only — never both protocols for the +// same provider request (no OpenAI→Anthropic error fallback). + +func TestProviderProtocolMatrix_OpenAIOnlyHaveNoAnthropicTransport(t *testing.T) { + t.Parallel() + openaiOnly := []string{ + "agnes", "kimi", "openai", "grok", "openrouter", "groq", + "canopywave", "poolside", "clinepass", "ollama", "azure", + } + for _, id := range openaiOnly { + spec, ok := registry.SpecByProviderID(id) + if !ok { + t.Fatalf("missing %q", id) + } + if spec.ProtocolID == "anthropic-messages" || spec.TransportKind == "anthropic" { + t.Fatalf("%s must not use anthropic transport (ProtocolID=%q TransportKind=%q)", + id, spec.ProtocolID, spec.TransportKind) + } + if spec.ProtocolID != "openai-chat-completions" && id != "azure" { + // azure is still openai-chat-completions + if spec.ProtocolID != "openai-chat-completions" { + t.Fatalf("%s ProtocolID = %q, want openai-chat-completions", id, spec.ProtocolID) + } + } + } +} + +func TestProviderProtocolMatrix_DualOfficialStayOpenAIPrimary(t *testing.T) { + t.Parallel() + // Catalog primary protocol is OpenAI chat completions. Hawk clients for these + // providers use OpenAI only (OpenCode Go is the exception: per-model single + // protocol, still no cross-protocol fallback). + dualPrimaryOpenAI := []string{ + "deepseek", "zai_payg", "zai_coding", + "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", + "minimax_payg", "minimax_token_plan", + "longcat", "opencodego", + } + for _, id := range dualPrimaryOpenAI { + spec, ok := registry.SpecByProviderID(id) + if !ok { + t.Fatalf("missing %q", id) + } + if spec.ProtocolID != "openai-chat-completions" { + t.Fatalf("%s catalog primary = %q, want openai-chat-completions", id, spec.ProtocolID) + } + if spec.TransportKind == "anthropic" { + t.Fatalf("%s must not be anthropic-primary in catalog", id) + } + } +} + +func TestProviderProtocolMatrix_AnthropicPrimary(t *testing.T) { + t.Parallel() + for _, id := range []string{"anthropic", "bedrock"} { + spec, ok := registry.SpecByProviderID(id) + if !ok { + t.Fatalf("missing %q", id) + } + if spec.ProtocolID != "anthropic-messages" { + t.Fatalf("%s ProtocolID = %q, want anthropic-messages", id, spec.ProtocolID) + } + } +} diff --git a/catalog/registry/provider_spec_test.go b/catalog/registry/provider_spec_test.go index a8bb64a4..1aa797be 100644 --- a/catalog/registry/provider_spec_test.go +++ b/catalog/registry/provider_spec_test.go @@ -9,23 +9,41 @@ import ( func TestAllProviders_Count(t *testing.T) { t.Parallel() - if n := len(registry.All()); n != 23 { - t.Fatalf("expected 23 providers, got %d", n) + if n := len(registry.All()); n != 25 { + t.Fatalf("expected 25 providers, got %d", n) } } -func TestCredentialRegistry_MatchesAll(t *testing.T) { +func TestProviderSpecs_AgnesOpenAIOnlyLongCatOpenAIPrimary(t *testing.T) { t.Parallel() - if len(registry.CredentialRegistry()) != len(registry.All()) { - t.Fatal("credential registry should cover all provider specs") + // Agnes: official docs advertise OpenAI-compatible only — no Anthropic surface. + agnes, ok := registry.SpecByProviderID("agnes") + if !ok { + t.Fatal("missing agnes") + } + if agnes.ProtocolID != "openai-chat-completions" || agnes.AdapterID != "openai" { + t.Fatalf("agnes protocol/adapter = %q/%q", agnes.ProtocolID, agnes.AdapterID) + } + if got := registry.DirectFallbackProviderIDs("agnes"); len(got) != 0 { + t.Fatalf("agnes DirectFallbacks = %v, want none", got) + } + + // LongCat: official docs expose BOTH OpenAI (/openai) and Anthropic (/anthropic). + // Hawk uses the OpenAI primary only — Anthropic is not required when OpenAI works. + longcat, ok := registry.SpecByProviderID("longcat") + if !ok { + t.Fatal("missing longcat") + } + if longcat.ProtocolID != "openai-chat-completions" || longcat.AdapterID != "openai" { + t.Fatalf("longcat protocol/adapter = %q/%q", longcat.ProtocolID, longcat.AdapterID) } } func TestLiveFetcherKeys_AllProviders(t *testing.T) { t.Parallel() keys := registry.LiveFetcherKeys() - if len(keys) != 23 { - t.Fatalf("expected 23 live fetcher keys, got %d", len(keys)) + if len(keys) != 25 { + t.Fatalf("expected 25 live fetcher keys, got %d", len(keys)) } } @@ -46,6 +64,23 @@ func TestOpenCodeGo_HasProbeBaseURL(t *testing.T) { } } +func TestConcentrateUsesResponsesAPI(t *testing.T) { + t.Parallel() + spec, ok := registry.SpecByProviderID("concentrate") + if !ok { + t.Fatal("missing Concentrate provider spec") + } + if spec.ProtocolID != "openai-responses" { + t.Fatalf("protocol = %q", spec.ProtocolID) + } + if spec.AdapterID != "concentrate-responses" { + t.Fatalf("adapter = %q", spec.AdapterID) + } + if !spec.PublicModelCatalog { + t.Fatal("Concentrate model catalog must be public") + } +} + func TestProviderRuntimePolicy_Metadata(t *testing.T) { t.Parallel() @@ -101,7 +136,7 @@ func TestProviderSpecs_TableDriven(t *testing.T) { {"bedrock", "bedrock", true, registry.ProbeNone, true, "anthropic-bedrock"}, {"vertex", "vertex", true, registry.ProbeNone, true, "gemini-vertex"}, {"openrouter", "openrouter", true, registry.ProbeOpenAIModels, true, "openrouter"}, - {"concentrate", "concentrate", true, registry.ProbeOpenAIModels, true, "concentrate-direct"}, + {"concentrate", "concentrate", true, registry.ProbeOpenAIModels, true, "concentrate-payg"}, {"grok", "grok", true, registry.ProbeOpenAIModels, true, "grok-direct"}, {"zai_payg", "zai_payg", true, registry.ProbeOpenAIModels, true, "zai_payg-direct"}, {"zai_coding", "zai_coding", true, registry.ProbeOpenAIModels, true, "zai_coding-direct"}, diff --git a/catalog/registry/providers.go b/catalog/registry/providers.go index ee44a4e7..99d36388 100644 --- a/catalog/registry/providers.go +++ b/catalog/registry/providers.go @@ -17,7 +17,7 @@ func providerSpecs() []ProviderSpec { return []ProviderSpec{ // ── Direct API providers ────────────────────────────────────────── { - ProviderID: "anthropic", DisplayName: "Anthropic", DeploymentID: "anthropic-direct", SortOrder: 1, ChatPreference: 2, + ProviderID: "anthropic", DisplayName: "Anthropic", DeploymentID: "anthropic-direct", SortOrder: 3, ChatPreference: 2, TransportKind: "anthropic", RequiresKey: true, CredentialEnv: "ANTHROPIC_API_KEY", CredentialAliases: []string{"CLAUDE_API_KEY"}, @@ -28,7 +28,7 @@ func providerSpecs() []ProviderSpec { DirectFallbacks: []string{"openai"}, }, { - ProviderID: "openai", DisplayName: "OpenAI", DeploymentID: "openai-direct", SortOrder: 2, ChatPreference: 1, + ProviderID: "openai", DisplayName: "OpenAI", DeploymentID: "openai-direct", SortOrder: 15, ChatPreference: 1, TransportKind: "openai", RequiresKey: true, CredentialEnv: "OPENAI_API_KEY", BaseURLEnv: []string{"OPENAI_BASE_URL", "OPENAI_API_BASE"}, @@ -38,7 +38,7 @@ func providerSpecs() []ProviderSpec { DirectFallbacks: []string{"anthropic"}, }, { - ProviderID: "gemini", DisplayName: "Gemini API", DeploymentID: "gemini-direct", SortOrder: 3, ChatPreference: 5, + ProviderID: "gemini", DisplayName: "Gemini API", DeploymentID: "gemini-direct", SortOrder: 9, ChatPreference: 5, RuntimeBaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", RequiresKey: true, CredentialEnv: "GEMINI_API_KEY", CredentialAliases: []string{"GOOGLE_API_KEY"}, @@ -48,7 +48,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "gemini-generate-content", AdapterID: "gemini", RuntimeProfileKey: "gemini", }, { - ProviderID: "deepseek", DisplayName: "DeepSeek", DeploymentID: "deepseek-direct", SortOrder: 4, ChatPreference: 11, + ProviderID: "deepseek", DisplayName: "DeepSeek", DeploymentID: "deepseek-direct", SortOrder: 8, ChatPreference: 11, RequiresKey: true, CredentialEnv: "DEEPSEEK_API_KEY", BaseURLEnv: []string{"DEEPSEEK_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.deepseek.com/v1", @@ -56,7 +56,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "deepseek", RuntimeProfileKey: "deepseek", }, { - ProviderID: "grok", DisplayName: "xAI", DeploymentID: "grok-direct", SortOrder: 5, ChatPreference: 4, + ProviderID: "grok", DisplayName: "xAI", DeploymentID: "grok-direct", SortOrder: 21, ChatPreference: 4, RequiresKey: true, CredentialEnv: "XAI_API_KEY", BaseURLEnv: []string{"XAI_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.x.ai/v1", @@ -64,7 +64,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "grok", RuntimeProfileKey: "grok", }, { - ProviderID: "kimi", DisplayName: "Kimi", DeploymentID: "kimi-direct", SortOrder: 6, ChatPreference: 14, + ProviderID: "kimi", DisplayName: "Kimi", DeploymentID: "kimi-direct", SortOrder: 11, ChatPreference: 14, RequiresKey: true, CredentialEnv: "MOONSHOT_API_KEY", BaseURLEnv: []string{"MOONSHOT_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.moonshot.ai/v1", @@ -72,7 +72,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "kimi", RuntimeProfileKey: "kimi", }, { - ProviderID: "zai_coding", DisplayName: "Z.AI — Coding Plan", DeploymentID: "zai_coding-direct", SortOrder: 7, ChatPreference: 8, + ProviderID: "zai_coding", DisplayName: "Z.AI — Coding Plan", DeploymentID: "zai_coding-direct", SortOrder: 24, ChatPreference: 8, RequiresKey: true, CredentialEnv: "ZAI_CODING_API_KEY", BaseURLEnv: []string{"ZAI_CODING_BASE_URL", "ZAI_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/coding/paas/v4", @@ -81,7 +81,7 @@ func providerSpecs() []ProviderSpec { PrepareCredentialEnv: true, }, { - ProviderID: "zai_payg", DisplayName: "Z.AI — Pay-as-you-go", DeploymentID: "zai_payg-direct", SortOrder: 8, ChatPreference: 9, + ProviderID: "zai_payg", DisplayName: "Z.AI — Pay-as-you-go", DeploymentID: "zai_payg-direct", SortOrder: 25, ChatPreference: 9, RequiresKey: true, CredentialEnv: "ZAI_API_KEY", BaseURLEnv: []string{"ZAI_BASE_URL", "ZAI_API_BASE"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.z.ai/api/paas/v4", @@ -90,7 +90,7 @@ func providerSpecs() []ProviderSpec { PrepareCredentialEnv: true, }, { - ProviderID: "xiaomi_mimo_token_plan", DisplayName: "Xiaomi MiMo — Token Plan", DeploymentID: "xiaomi_mimo_token_plan-direct", SortOrder: 9, ChatPreference: 16, + ProviderID: "xiaomi_mimo_token_plan", DisplayName: "Xiaomi MiMo — Token Plan", DeploymentID: "xiaomi_mimo_token_plan-direct", SortOrder: 23, ChatPreference: 16, RequiresKey: true, CredentialEnv: "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", BaseURLEnv: []string{"XIAOMI_MIMO_TOKEN_PLAN_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "", @@ -99,7 +99,7 @@ func providerSpecs() []ProviderSpec { PrepareCredentialEnv: true, }, { - ProviderID: "xiaomi_mimo_payg", DisplayName: "Xiaomi MiMo — Pay-as-you-go", DeploymentID: "xiaomi_mimo_payg-direct", SortOrder: 10, ChatPreference: 15, + ProviderID: "xiaomi_mimo_payg", DisplayName: "Xiaomi MiMo — Pay-as-you-go", DeploymentID: "xiaomi_mimo_payg-direct", SortOrder: 22, ChatPreference: 15, RequiresKey: true, CredentialEnv: "XIAOMI_MIMO_PAYG_API_KEY", CredentialAliases: []string{"XIAOMI_MIMO_API_KEY"}, BaseURLEnv: []string{"XIAOMI_MIMO_PAYG_BASE_URL", "XIAOMI_BASE_URL"}, @@ -108,7 +108,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "xiaomi_mimo", RuntimeProfileKey: "xiaomi_mimo_payg", }, { - ProviderID: "minimax_token_plan", DisplayName: "MiniMax — Token Plan", DeploymentID: "minimax_token_plan-direct", SortOrder: 11, ChatPreference: 17, + ProviderID: "minimax_token_plan", DisplayName: "MiniMax — Token Plan", DeploymentID: "minimax_token_plan-direct", SortOrder: 14, ChatPreference: 17, RequiresKey: true, CredentialEnv: "MINIMAX_TOKEN_PLAN_API_KEY", BaseURLEnv: []string{"MINIMAX_TOKEN_PLAN_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.minimax.io/v1", @@ -116,7 +116,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "minimax_token_plan", }, { - ProviderID: "minimax_payg", DisplayName: "MiniMax — Pay-as-you-go", DeploymentID: "minimax_payg-direct", SortOrder: 12, ChatPreference: 18, + ProviderID: "minimax_payg", DisplayName: "MiniMax — Pay-as-you-go", DeploymentID: "minimax_payg-direct", SortOrder: 13, ChatPreference: 18, RequiresKey: true, CredentialEnv: "MINIMAX_PAYG_API_KEY", BaseURLEnv: []string{"MINIMAX_PAYG_BASE_URL", "MINIMAX_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.minimax.io/v1", @@ -126,7 +126,7 @@ func providerSpecs() []ProviderSpec { // ── Cloud platform providers ────────────────────────────────────── { - ProviderID: "azure", DisplayName: "Azure OpenAI", DeploymentID: "openai-azure", SortOrder: 13, ChatPreference: 12, + ProviderID: "azure", DisplayName: "Azure OpenAI", DeploymentID: "openai-azure", SortOrder: 4, ChatPreference: 12, TransportKind: "azure", RequiresKey: true, CredentialEnv: "AZURE_OPENAI_API_KEY", BaseURLEnv: []string{"AZURE_OPENAI_ENDPOINT"}, @@ -135,7 +135,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "openai-azure", RuntimeProfileKey: "azure", }, { - ProviderID: "bedrock", DisplayName: "Amazon Bedrock", DeploymentID: "anthropic-bedrock", SortOrder: 14, ChatPreference: 7, + ProviderID: "bedrock", DisplayName: "Amazon Bedrock", DeploymentID: "anthropic-bedrock", SortOrder: 2, ChatPreference: 7, TransportKind: "bedrock", RequiresKey: true, CredentialEnv: "AWS_SECRET_ACCESS_KEY", CredentialEnvFallbacks: []string{"AWS_ACCESS_KEY_ID", "AWS_SESSION_TOKEN"}, @@ -145,7 +145,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "anthropic-messages", AdapterID: "anthropic-bedrock", RuntimeProfileKey: "bedrock", }, { - ProviderID: "vertex", DisplayName: "Vertex AI", DeploymentID: "gemini-vertex", SortOrder: 15, ChatPreference: 6, + ProviderID: "vertex", DisplayName: "Vertex AI", DeploymentID: "gemini-vertex", SortOrder: 20, ChatPreference: 6, TransportKind: "vertex", RequiresKey: true, CredentialEnv: "VERTEX_ACCESS_TOKEN", CredentialEnvFallbacks: []string{"GOOGLE_OAUTH_ACCESS_TOKEN"}, @@ -157,7 +157,7 @@ func providerSpecs() []ProviderSpec { // ── Aggregators ─────────────────────────────────────────────────── { - ProviderID: "openrouter", DisplayName: "OpenRouter", DeploymentID: "openrouter", SortOrder: 16, ChatPreference: 3, + ProviderID: "openrouter", DisplayName: "OpenRouter", DeploymentID: "openrouter", SortOrder: 17, ChatPreference: 3, RequiresKey: true, CredentialEnv: "OPENROUTER_API_KEY", BaseURLEnv: []string{"OPENROUTER_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://openrouter.ai/api/v1", @@ -165,17 +165,34 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "openrouter", RuntimeProfileKey: "openrouter", }, { - ProviderID: "concentrate", DisplayName: "Concentrate AI", DeploymentID: "concentrate-direct", SortOrder: 16, ChatPreference: 4, + ProviderID: "concentrate", DisplayName: "Concentrate AI (Pay-as-you-go)", DeploymentID: "concentrate-payg", SortOrder: 7, ChatPreference: 4, RequiresKey: true, CredentialEnv: "CONCENTRATE_API_KEY", BaseURLEnv: []string{"CONCENTRATE_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.concentrate.ai/v1", LiveFetcherKey: "concentrate", LiveCatalogKey: "concentrate", - ProtocolID: "openai-chat-completions", AdapterID: "concentrate", RuntimeProfileKey: "concentrate", + PublicModelCatalog: true, + ProtocolID: "openai-responses", AdapterID: "concentrate-responses", RuntimeProfileKey: "concentrate", + }, + { + ProviderID: "agnes", DisplayName: "Agnes AI", DeploymentID: "agnes-direct", SortOrder: 1, ChatPreference: 25, + RequiresKey: true, CredentialEnv: "AGNES_API_KEY", + BaseURLEnv: []string{"AGNES_BASE_URL"}, + ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://apihub.agnes-ai.com/v1", + LiveFetcherKey: "agnes", LiveCatalogKey: "agnes", + ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "agnes", + }, + { + ProviderID: "longcat", DisplayName: "LongCat", DeploymentID: "longcat-direct", SortOrder: 12, ChatPreference: 26, + RequiresKey: true, CredentialEnv: "LONGCAT_API_KEY", + BaseURLEnv: []string{"LONGCAT_BASE_URL"}, + ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.longcat.chat/openai/v1", + LiveFetcherKey: "longcat", LiveCatalogKey: "longcat", + ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "longcat", }, // ── Niche ───────────────────────────────────────────────────────── { - ProviderID: "canopywave", DisplayName: "CanopyWave", DeploymentID: "canopywave", SortOrder: 17, ChatPreference: 10, + ProviderID: "canopywave", DisplayName: "CanopyWave", DeploymentID: "canopywave", SortOrder: 5, ChatPreference: 10, RequiresKey: true, CredentialEnv: "CANOPYWAVE_API_KEY", BaseURLEnv: []string{"CANOPYWAVE_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://inference.canopywave.io/v1", @@ -183,7 +200,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "canopywave", RuntimeProfileKey: "canopywave", }, { - ProviderID: "poolside", DisplayName: "Poolside", DeploymentID: "poolside", SortOrder: 18, ChatPreference: 20, + ProviderID: "poolside", DisplayName: "Poolside", DeploymentID: "poolside", SortOrder: 19, ChatPreference: 20, RequiresKey: true, CredentialEnv: "POOLSIDE_API_KEY", BaseURLEnv: []string{"POOLSIDE_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://inference.poolside.ai/v1", @@ -191,7 +208,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "poolside", RuntimeProfileKey: "poolside", }, { - ProviderID: "groq", DisplayName: "Groq", DeploymentID: "groq-direct", SortOrder: 19, ChatPreference: 21, + ProviderID: "groq", DisplayName: "Groq", DeploymentID: "groq-direct", SortOrder: 10, ChatPreference: 21, RequiresKey: true, CredentialEnv: "GROQ_API_KEY", BaseURLEnv: []string{"GROQ_BASE_URL"}, ProbeKind: ProbeOpenAIModels, ProbeBaseURL: "https://api.groq.com/openai/v1", @@ -199,7 +216,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "groq", RuntimeProfileKey: "groq", }, { - ProviderID: "clinepass", DisplayName: "ClinePass", DeploymentID: "clinepass", SortOrder: 20, ChatPreference: 22, + ProviderID: "clinepass", DisplayName: "ClinePass", DeploymentID: "clinepass", SortOrder: 6, ChatPreference: 22, RuntimeBaseURL: "https://api.cline.bot/api/v1", RequiresKey: true, CredentialEnv: "CLINE_API_KEY", BaseURLEnv: []string{"CLINE_API_BASE"}, @@ -208,7 +225,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "clinepass", RuntimeProfileKey: "clinepass", }, { - ProviderID: "opencodego", DisplayName: "OpenCode Go", DeploymentID: "opencodego", SortOrder: 21, ChatPreference: 13, + ProviderID: "opencodego", DisplayName: "OpenCode Go", DeploymentID: "opencodego", SortOrder: 16, ChatPreference: 13, RequiresKey: true, CredentialEnv: "OPENCODEGO_API_KEY", BaseURLEnv: []string{"OPENCODEGO_BASE_URL"}, ProbeKind: ProbeOpenAIModels, @@ -219,7 +236,7 @@ func providerSpecs() []ProviderSpec { // ── Local ───────────────────────────────────────────────────────── { - ProviderID: "ollama", DisplayName: "Ollama", DeploymentID: "ollama-local", SortOrder: 22, ChatPreference: 19, + ProviderID: "ollama", DisplayName: "Ollama", DeploymentID: "ollama-local", SortOrder: 18, ChatPreference: 19, RuntimeBaseURL: "http://localhost:11434/v1", RuntimeCredentialEnv: "OLLAMA_API_KEY", RequiresKey: false, CredentialEnv: "OLLAMA_BASE_URL", BaseURLEnv: []string{"OLLAMA_BASE_URL"}, diff --git a/catalog/registry/spec.go b/catalog/registry/spec.go index f2232e71..6c415a30 100644 --- a/catalog/registry/spec.go +++ b/catalog/registry/spec.go @@ -38,6 +38,7 @@ type ProviderSpec struct { ProbeBaseURL string LiveFetcherKey string // key in catalog/live registry LiveCatalogKey string // legacy provider key in ModelCatalog.Providers map + PublicModelCatalog bool // live model listing is available without provider credentials ProtocolID string AdapterID string RuntimeProfileKey string diff --git a/catalog/topology.go b/catalog/topology.go new file mode 100644 index 00000000..768048e3 --- /dev/null +++ b/catalog/topology.go @@ -0,0 +1,75 @@ +package catalog + +import "github.com/GrayCodeAI/eyrie/catalog/registry" + +// PruneUnreferencedDeployments makes the embedded catalog and provider registry +// authoritative for first-class providers, then removes unrelated orphan +// deployments. Referenced deployments for custom providers remain valid. +func PruneUnreferencedDeployments(c *Catalog) { + if c == nil || len(c.Deployments) == 0 { + return + } + + keep := make(map[string]struct{}, len(c.Deployments)) + knownProviderDeployments := map[string]map[string]struct{}{} + defaults := defaultDeployments() + for id, deployment := range defaults { + keep[id] = struct{}{} + providerID := CanonicalProviderID(deployment.ProviderID) + if knownProviderDeployments[providerID] == nil { + knownProviderDeployments[providerID] = map[string]struct{}{} + } + knownProviderDeployments[providerID][id] = struct{}{} + } + for _, spec := range registry.DefaultRegistry.All() { + keep[spec.DeploymentID] = struct{}{} + providerID := CanonicalProviderID(spec.ProviderID) + if deployment, ok := defaults[spec.DeploymentID]; ok { + providerID = CanonicalProviderID(deployment.ProviderID) + } + if knownProviderDeployments[providerID] == nil { + knownProviderDeployments[providerID] = map[string]struct{}{} + } + knownProviderDeployments[providerID][spec.DeploymentID] = struct{}{} + } + for _, offering := range c.Offerings { + keep[offering.DeploymentID] = struct{}{} + } + for _, template := range c.OfferingTemplates { + keep[template.DeploymentID] = struct{}{} + } + + remove := map[string]struct{}{} + for id, deployment := range c.Deployments { + providerID := CanonicalProviderID(deployment.ProviderID) + if allowed := knownProviderDeployments[providerID]; len(allowed) > 0 { + if _, canonical := allowed[id]; !canonical { + remove[id] = struct{}{} + continue + } + } + if _, retained := keep[id]; !retained { + remove[id] = struct{}{} + } + } + for id := range remove { + delete(c.Deployments, id) + } + if len(remove) == 0 { + return + } + offerings := c.Offerings[:0] + for _, offering := range c.Offerings { + if _, removed := remove[offering.DeploymentID]; !removed { + offerings = append(offerings, offering) + } + } + c.Offerings = offerings + templates := c.OfferingTemplates[:0] + for _, template := range c.OfferingTemplates { + if _, removed := remove[template.DeploymentID]; !removed { + templates = append(templates, template) + } + } + c.OfferingTemplates = templates +} diff --git a/catalog/topology_test.go b/catalog/topology_test.go new file mode 100644 index 00000000..11ea7fa3 --- /dev/null +++ b/catalog/topology_test.go @@ -0,0 +1,76 @@ +package catalog + +import "testing" + +func TestPruneUnreferencedDeployments(t *testing.T) { + t.Parallel() + + c := SeedCatalog() + c.Deployments["stale-orphan"] = Deployment{ + ID: "stale-orphan", + Name: "Stale orphan", + ProviderID: "concentrate", + APIProtocolID: "openai-chat-completions", + AdapterConstructor: "concentrate", + NativeModelIDSource: NativeModelIDDiscovered, + } + c.Deployments["external-referenced"] = Deployment{ + ID: "external-referenced", + Name: "External referenced", + ProviderID: "custom-concentrate", + APIProtocolID: "openai-chat-completions", + AdapterConstructor: "concentrate", + NativeModelIDSource: NativeModelIDDiscovered, + } + c.Providers["custom-concentrate"] = Provider{ID: "custom-concentrate", Name: "Custom Concentrate"} + c.Offerings = append(c.Offerings, ModelOffering{ + ID: "external-referenced:native-model", + CanonicalModelID: "custom-concentrate/native-model", + DeploymentID: "external-referenced", + NativeModelID: "native-model", + }) + c.Models["custom-concentrate/native-model"] = Model{ + ID: "custom-concentrate/native-model", + ProviderID: "custom-concentrate", + Name: "Native model", + } + c.Deployments["obsolete-first-class"] = Deployment{ + ID: "obsolete-first-class", + Name: "Obsolete first-class deployment", + ProviderID: "concentrate", + APIProtocolID: "openai-chat-completions", + AdapterConstructor: "concentrate", + NativeModelIDSource: NativeModelIDDiscovered, + } + c.Offerings = append(c.Offerings, ModelOffering{ + ID: "obsolete-first-class:old-model", + CanonicalModelID: "concentrate/old-model", + DeploymentID: "obsolete-first-class", + NativeModelID: "old-model", + }) + c.Models["concentrate/old-model"] = Model{ + ID: "concentrate/old-model", + ProviderID: "concentrate", + Name: "Old model", + } + + PruneUnreferencedDeployments(&c) + + if _, exists := c.Deployments["stale-orphan"]; exists { + t.Fatal("unregistered orphan deployment was not pruned") + } + if _, exists := c.Deployments["external-referenced"]; !exists { + t.Fatal("referenced external deployment was pruned") + } + if _, exists := c.Deployments["concentrate-payg"]; !exists { + t.Fatal("registered deployment was pruned") + } + if _, exists := c.Deployments["obsolete-first-class"]; exists { + t.Fatal("obsolete first-class deployment was not pruned") + } + for _, offering := range c.Offerings { + if offering.DeploymentID == "obsolete-first-class" { + t.Fatal("offering for obsolete first-class deployment was not pruned") + } + } +} diff --git a/catalog/v1.go b/catalog/v1.go index ceba9825..fdd29580 100644 --- a/catalog/v1.go +++ b/catalog/v1.go @@ -180,25 +180,91 @@ func CapabilitySetFromEntry(e live.Entry) CapabilitySet { set.ServerTools[feat] = CapabilitySupported case "function_calling", "function-calling", "tools": set.FunctionCalling = CapabilitySupported + case "thinking:enabled": + set.ExplicitThinkingBudget = CapabilitySupported + set.ThinkingTypes = appendUniqueString(set.ThinkingTypes, "enabled") + case "thinking:adaptive": + set.AdaptiveThinking = CapabilitySupported + set.ThinkingTypes = appendUniqueString(set.ThinkingTypes, "adaptive") + case "effort": + set.Effort = CapabilitySupported + case "structured_output": + set.StructuredOutput = CapabilitySupported + case "code_execution": + set.CodeExecution = CapabilitySupported + case "citations": + set.Citations = CapabilitySupported + case "pdf_input": + set.PDFInput = CapabilitySupported + case "image_input", "vision": + set.ImageInput = CapabilitySupported } } if len(set.ServerTools) == 0 && len(e.Features) > 0 { for _, feat := range e.Features { - set.ServerTools[feat] = CapabilitySupported + switch strings.ToLower(strings.TrimSpace(feat)) { + case "function_calling", "function-calling", "tools", + "thinking:enabled", "thinking:adaptive", "effort", + "structured_output", "code_execution", "citations", + "pdf_input", "image_input", "vision": + continue + default: + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(feat)), "effort:") { + continue + } + set.ServerTools[feat] = CapabilitySupported + } } } if e.MaxOutput > 0 { set.MaxOutputTokens = e.MaxOutput } + if e.ContextWindow > 0 { + set.MaxInputTokens = e.ContextWindow + } else if e.MaxInputTokens > 0 { + set.MaxInputTokens = e.MaxInputTokens + } + if e.ThinkingEnabled { + set.ExplicitThinkingBudget = CapabilitySupported + set.ThinkingTypes = appendUniqueString(set.ThinkingTypes, "enabled") + } + if e.ThinkingAdaptive { + set.AdaptiveThinking = CapabilitySupported + set.ThinkingTypes = appendUniqueString(set.ThinkingTypes, "adaptive") + } + if e.EffortSupported { + set.Effort = CapabilitySupported + } + if e.EffortLevels != "" { + set.EffortLevels = strings.Split(e.EffortLevels, ",") + } if e.StructuredOutput { set.StructuredOutput = CapabilitySupported } if e.CodeExecution { set.CodeExecution = CapabilitySupported } + if e.CitationsSupported { + set.Citations = CapabilitySupported + } + if e.PDFInput { + set.PDFInput = CapabilitySupported + } + if e.ImageInput { + set.ImageInput = CapabilitySupported + } return set } +func appendUniqueString(slice []string, s string) []string { + for _, existing := range slice { + if existing == s { + return slice + } + } + return append(slice, s) +} + // PricingFromEntry builds a Pricing from a live entry's per-token rates. func PricingFromEntry(e live.Entry) Pricing { in := e.InputPricePer1M diff --git a/catalog/v1_defaults.go b/catalog/v1_defaults.go index 9f01a20f..31881199 100644 --- a/catalog/v1_defaults.go +++ b/catalog/v1_defaults.go @@ -15,7 +15,7 @@ func defaultProviders() map[string]Provider { "google": {ID: "google", Name: "Google"}, "xai": {ID: "xai", Name: "xAI"}, "openrouter": {ID: "openrouter", Name: "OpenRouter"}, - "concentrate": {ID: "concentrate", Name: "Concentrate AI"}, + "concentrate": {ID: "concentrate", Name: "Concentrate AI (Pay-as-you-go)"}, "canopywave": {ID: "canopywave", Name: "CanopyWave"}, "zai_payg": {ID: "zai_payg", Name: "Z.AI Pay-as-you-go"}, "zai_coding": {ID: "zai_coding", Name: "Z.AI Coding Plan"}, @@ -33,6 +33,7 @@ func defaultProviders() map[string]Provider { func defaultProtocols() map[string]Protocol { return map[string]Protocol{ "openai-chat-completions": {ID: "openai-chat-completions", Name: "OpenAI Chat Completions"}, + "openai-responses": {ID: "openai-responses", Name: "OpenAI Responses"}, } } @@ -48,7 +49,7 @@ func defaultDeployments() map[string]Deployment { "gemini-vertex": deployment("gemini-vertex", "Gemini on Vertex", "google", "openai-chat-completions", "gemini-vertex", NativeModelIDCatalogKnown), "grok-direct": deployment("grok-direct", "Grok", "xai", "openai-chat-completions", "grok", NativeModelIDCatalogKnown), "openrouter": deployment("openrouter", "OpenRouter", "openrouter", "openai-chat-completions", "openrouter", NativeModelIDDiscovered), - "concentrate-direct": deployment("concentrate-direct", "Concentrate AI", "concentrate", "openai-chat-completions", "concentrate", NativeModelIDDiscovered), + "concentrate-payg": deployment("concentrate-payg", "Concentrate AI (Pay-as-you-go)", "concentrate", "openai-responses", "concentrate-responses", NativeModelIDDiscovered), "zai_payg-direct": deployment("zai_payg-direct", "Z.AI Pay-as-you-go", "zai_payg", "openai-chat-completions", "zai_payg", NativeModelIDCatalogKnown), "zai_coding-direct": deployment("zai_coding-direct", "Z.AI Coding Plan", "zai_coding", "openai-chat-completions", "zai_coding", NativeModelIDCatalogKnown), "canopywave": deployment("canopywave", "CanopyWave", "canopywave", "openai-chat-completions", "canopywave", NativeModelIDDiscovered), diff --git a/catalog/v1_test.go b/catalog/v1_test.go index 0a884903..349f6e3b 100644 --- a/catalog/v1_test.go +++ b/catalog/v1_test.go @@ -238,6 +238,32 @@ func TestCapabilitySetFromEntry_ToolsAliasSupportsFunctionCalling(t *testing.T) } } +func TestCapabilitySetFromEntry_KeepsContextAndMaxOutput(t *testing.T) { + t.Parallel() + set := CapabilitySetFromEntry(live.Entry{ + ContextWindow: 1_048_576, + MaxOutput: 131_072, + Features: []string{"tools", "thinking:enabled", "image_input"}, + ThinkingEnabled: true, + ImageInput: true, + }) + if set.MaxInputTokens != 1_048_576 { + t.Fatalf("MaxInputTokens = %d", set.MaxInputTokens) + } + if set.MaxOutputTokens != 131_072 { + t.Fatalf("MaxOutputTokens = %d", set.MaxOutputTokens) + } + if set.FunctionCalling != CapabilitySupported { + t.Fatalf("FunctionCalling = %q", set.FunctionCalling) + } + if set.ExplicitThinkingBudget != CapabilitySupported { + t.Fatalf("ExplicitThinkingBudget = %q", set.ExplicitThinkingBudget) + } + if set.ImageInput != CapabilitySupported { + t.Fatalf("ImageInput = %q", set.ImageInput) + } +} + func TestCapabilitySetFromLegacy_EmptyFeatures(t *testing.T) { t.Parallel() entry := ModelCatalogEntry{ID: "test-model"} diff --git a/catalog/xiaomi/endpoints.go b/catalog/xiaomi/endpoints.go index 57b79038..021fdb51 100644 --- a/catalog/xiaomi/endpoints.go +++ b/catalog/xiaomi/endpoints.go @@ -24,14 +24,10 @@ const ( ) const ( - PayAsYouGoOpenAIBase = "https://api.xiaomimimo.com/v1" - PayAsYouGoAnthropicBase = "https://api.xiaomimimo.com/anthropic" - TokenPlanCNOpenAIBase = "https://token-plan-cn.xiaomimimo.com/v1" // #nosec G101 -- public API base URL, not a secret value - TokenPlanCNAnthropicBase = "https://token-plan-cn.xiaomimimo.com/anthropic" // #nosec G101 -- public API base URL, not a secret value - TokenPlanSGPOpenAIBase = "https://token-plan-sgp.xiaomimimo.com/v1" // #nosec G101 -- public API base URL, not a secret value - TokenPlanSGPAnthropicBase = "https://token-plan-sgp.xiaomimimo.com/anthropic" // #nosec G101 -- public API base URL, not a secret value - TokenPlanAMSOpenAIBase = "https://token-plan-ams.xiaomimimo.com/v1" // #nosec G101 -- public API base URL, not a secret value - TokenPlanAMSAnthropicBase = "https://token-plan-ams.xiaomimimo.com/anthropic" // #nosec G101 -- public API base URL, not a secret value + PayAsYouGoOpenAIBase = "https://api.xiaomimimo.com/v1" + TokenPlanCNOpenAIBase = "https://token-plan-cn.xiaomimimo.com/v1" // #nosec G101 -- public API base URL, not a secret value + TokenPlanSGPOpenAIBase = "https://token-plan-sgp.xiaomimimo.com/v1" // #nosec G101 -- public API base URL, not a secret value + TokenPlanAMSOpenAIBase = "https://token-plan-ams.xiaomimimo.com/v1" // #nosec G101 -- public API base URL, not a secret value ) // ProviderPayAsYouGo is the registry / setup gateway id for pay-as-you-go. @@ -109,27 +105,6 @@ func ResolveOpenAIBase(billing Billing, region Region, override string) (string, } } -// ResolveAnthropicBase returns the Anthropic-compatible base URL (no /v1 suffix). -func ResolveAnthropicBase(billing Billing, region Region) (string, error) { - switch billing { - case BillingPayAsYouGo: - return PayAsYouGoAnthropicBase, nil - case BillingTokenPlan: - switch region { - case RegionCN: - return TokenPlanCNAnthropicBase, nil - case RegionSGP: - return TokenPlanSGPAnthropicBase, nil - case RegionAMS: - return TokenPlanAMSAnthropicBase, nil - default: - return "", fmt.Errorf("xiaomi: token plan region required (cn, sgp, ams)") - } - default: - return "", fmt.Errorf("xiaomi: unknown billing mode") - } -} - // KeyMismatchHint returns a user-facing hint when key shape may not match billing (never blocks save). func KeyMismatchHint(billing Billing, secret string) string { secret = strings.TrimSpace(secret) diff --git a/catalog/xiaomi/endpoints_test.go b/catalog/xiaomi/endpoints_test.go index cd5968ea..409505bc 100644 --- a/catalog/xiaomi/endpoints_test.go +++ b/catalog/xiaomi/endpoints_test.go @@ -6,26 +6,6 @@ import ( "testing" ) -func TestResolveAnthropicBase_MatchesOfficialPaths(t *testing.T) { - t.Parallel() - // Payg + token plan bases; AnthropicClient posts to baseURL + "/v1/messages". - payg, err := ResolveAnthropicBase(BillingPayAsYouGo, "") - if err != nil || payg != PayAsYouGoAnthropicBase { - t.Fatalf("payg anthropic = %q err=%v", payg, err) - } - want := payg + "/v1/messages" - if want != "https://api.xiaomimimo.com/anthropic/v1/messages" { - t.Fatalf("payg messages URL = %q", want) - } - cn, err := ResolveAnthropicBase(BillingTokenPlan, RegionCN) - if err != nil || cn != TokenPlanCNAnthropicBase { - t.Fatalf("cn anthropic = %q err=%v", cn, err) - } - if cn+"/v1/messages" != "https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages" { - t.Fatalf("cn messages URL mismatch") - } -} - func TestResolveOpenAIBase(t *testing.T) { t.Parallel() base, err := ResolveOpenAIBase(BillingPayAsYouGo, "", "") diff --git a/catalog/zai/endpoints.go b/catalog/zai/endpoints.go index 914c310b..c7707d6e 100644 --- a/catalog/zai/endpoints.go +++ b/catalog/zai/endpoints.go @@ -1,9 +1,6 @@ // Package zai resolves Z.AI (Zhipu GLM) API base URLs for General (pay-as-you-go) -// and Coding Plan subscriptions, with full support for both OpenAI-compatible -// and Anthropic-compatible endpoints, plus International vs China regions. -// -// This mirrors the structure of catalog/xiaomi/ for proper plan + region + dual-protocol separation. -// Official docs document separate "International" and "China" options for both General and Coding Plan. +// and Coding Plan subscriptions across International vs China regions. +// Hawk uses the OpenAI-compatible surface only. package zai import ( @@ -36,12 +33,6 @@ const ( CodingChinaOpenAIBase = "https://open.bigmodel.cn/api/coding/paas/v4" ) -// Anthropic-compatible bases. -const ( - InternationalAnthropicBase = "https://api.z.ai/api/anthropic" - ChinaAnthropicBase = "https://open.bigmodel.cn/api/anthropic" -) - // Provider IDs. const ( ProviderGeneral = "zai_payg" @@ -95,14 +86,6 @@ func ResolveOpenAIBase(plan Plan, region Region, override string) (string, error } } -// ResolveAnthropicBase returns the Anthropic-compat base for the region. -func ResolveAnthropicBase(region Region) string { - if region == RegionChina { - return ChinaAnthropicBase - } - return InternationalAnthropicBase -} - // KeyMismatchHint (kept for future key prefix detection). func KeyMismatchHint(plan Plan, secret string) string { secret = strings.TrimSpace(secret) diff --git a/catalog/zai/endpoints_test.go b/catalog/zai/endpoints_test.go index 580ce1ec..9c8f8f5d 100644 --- a/catalog/zai/endpoints_test.go +++ b/catalog/zai/endpoints_test.go @@ -93,27 +93,6 @@ func TestResolveOpenAIBase(t *testing.T) { } } -func TestResolveAnthropicBase(t *testing.T) { - t.Parallel() - tests := []struct { - name string - region Region - want string - }{ - {"International", RegionInternational, InternationalAnthropicBase}, - {"China", RegionChina, ChinaAnthropicBase}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ResolveAnthropicBase(tt.region) - if got != tt.want { - t.Errorf("ResolveAnthropicBase: got=%q, want=%q", got, tt.want) - } - }) - } -} - func TestKeyMismatchHint(t *testing.T) { t.Parallel() tests := []struct { diff --git a/client/adapters/anthropic.go b/client/adapters/anthropic.go index 13f714bd..e6c0f401 100644 --- a/client/adapters/anthropic.go +++ b/client/adapters/anthropic.go @@ -159,6 +159,12 @@ func thinkingDisabled() *anthropicThinking { } // resolveThinking builds the thinking config from core.ChatOptions. +// Explicit ThinkingMode / ThinkingBudgetTokens win. Otherwise ThinkingEnabled +// maps to Anthropic's documented toggle: +// - false → {type:"disabled"} +// - true → {type:"adaptive"} (recommended for current Claude models) +// +// See https://platform.claude.com/docs/en/build-with-claude/extended-thinking func resolveThinking(opts core.ChatOptions) *anthropicThinking { switch opts.ThinkingMode { case "adaptive": @@ -172,6 +178,16 @@ func resolveThinking(opts core.ChatOptions) *anthropicThinking { } return thinking default: + thinkingEnabled := opts.ThinkingEnabled + if thinkingEnabled == nil { + thinkingEnabled = opts.GLMThinkingEnabled + } + if thinkingEnabled != nil { + if !*thinkingEnabled { + return thinkingDisabled() + } + return thinkingAdaptive() + } // Legacy behavior: if budget > 0, enable with budget return thinkingForBudget(opts.ThinkingBudgetTokens) } diff --git a/client/adapters/anthropic_base.go b/client/adapters/anthropic_base.go new file mode 100644 index 00000000..24e048ca --- /dev/null +++ b/client/adapters/anthropic_base.go @@ -0,0 +1,49 @@ +package adapters + +import ( + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" + "github.com/GrayCodeAI/hawk-core-contracts/llm" +) + +// AnthropicBaseFromOpenAIV1 strips a trailing /v1 from an OpenAI-compatible base URL. +// OpenCode Go Anthropic models share the host without the /v1 suffix. +func AnthropicBaseFromOpenAIV1(openAIBase string) string { + base := strings.TrimRight(strings.TrimSpace(openAIBase), "/") + if strings.HasSuffix(base, "/v1") { + return strings.TrimSuffix(base, "/v1") + } + return base +} + +// streamResultFromChat synthesizes a StreamResult from a completed chat response. +// Used by Poolside when recovering a non-streaming answer into the stream path. +func streamResultFromChat(resp *core.EyrieResponse) *core.StreamResult { + out := make(chan core.EyrieStreamEvent, core.StreamChannelBuffer) + go func() { + defer close(out) + if resp == nil { + return + } + if strings.TrimSpace(resp.Thinking) != "" { + out <- core.EyrieStreamEvent{Type: "thinking", Thinking: resp.Thinking} + } + if strings.TrimSpace(resp.Content) != "" { + out <- core.EyrieStreamEvent{Type: "content", Content: resp.Content} + } + for i := range resp.ToolCalls { + tc := resp.ToolCalls[i] + out <- core.EyrieStreamEvent{Type: "tool_call", ToolCall: &tc} + } + if resp.Usage != nil { + out <- core.EyrieStreamEvent{Type: "usage", Usage: resp.Usage} + } + stop := resp.FinishReason + if stop == "" { + stop = "stop" + } + out <- core.EyrieStreamEvent{Type: "done", StopReason: stop} + }() + return llm.NewStreamResult(out, "", func() {}) +} diff --git a/client/adapters/anthropic_base_test.go b/client/adapters/anthropic_base_test.go new file mode 100644 index 00000000..57d67506 --- /dev/null +++ b/client/adapters/anthropic_base_test.go @@ -0,0 +1,102 @@ +package adapters + +import ( + "testing" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +func TestAnthropicBaseFromOpenAIV1(t *testing.T) { + t.Parallel() + if got := AnthropicBaseFromOpenAIV1("https://example.com/zen/go/v1"); got != "https://example.com/zen/go" { + t.Fatalf("got %q", got) + } + if got := AnthropicBaseFromOpenAIV1("https://example.com/zen/go"); got != "https://example.com/zen/go" { + t.Fatalf("got %q", got) + } + if got := AnthropicBaseFromOpenAIV1(" https://example.com/v1/ "); got != "https://example.com" { + t.Fatalf("got %q", got) + } +} + +func TestStreamResultFromChat(t *testing.T) { + t.Parallel() + result := streamResultFromChat(&core.EyrieResponse{ + Content: "Hi there!", + FinishReason: "stop", + }) + var content string + for event := range result.Events { + if event.Type == "content" { + content += event.Content + } + } + if content != "Hi there!" { + t.Fatalf("content = %q, want Hi there!", content) + } +} + +func TestStreamResultFromChat_FullResponse(t *testing.T) { + t.Parallel() + resp := &core.EyrieResponse{ + Thinking: "Let me think...", + Content: "Hello there!", + ToolCalls: []core.ToolCall{{Name: "get_weather", Arguments: map[string]interface{}{"city": "NYC"}}}, + Usage: &core.EyrieUsage{TotalTokens: 42}, + FinishReason: "", + } + result := streamResultFromChat(resp) + defer result.Close() + + var thinking, content string + var toolCalls int + var gotUsage, gotDone bool + var stopReason string + for evt := range result.Events { + switch evt.Type { + case "thinking": + thinking = evt.Thinking + case "content": + content = evt.Content + case "tool_call": + toolCalls++ + case "usage": + gotUsage = evt.Usage != nil + case "done": + gotDone = true + stopReason = evt.StopReason + } + } + if thinking != "Let me think..." { + t.Errorf("thinking = %q", thinking) + } + if content != "Hello there!" { + t.Errorf("content = %q", content) + } + if toolCalls != 1 { + t.Errorf("tool calls = %d", toolCalls) + } + if !gotUsage { + t.Error("expected usage event") + } + if !gotDone { + t.Error("expected done event") + } + if stopReason != "stop" { + t.Errorf("stop_reason = %q, expected 'stop'", stopReason) + } +} + +func TestStreamResultFromChat_NilResponse(t *testing.T) { + t.Parallel() + result := streamResultFromChat(nil) + defer result.Close() + + var eventCount int + for range result.Events { + eventCount++ + } + if eventCount != 0 { + t.Errorf("expected 0 events for nil response, got %d", eventCount) + } +} diff --git a/client/adapters/anthropic_test.go b/client/adapters/anthropic_test.go index ee8f5180..e7208a7a 100644 --- a/client/adapters/anthropic_test.go +++ b/client/adapters/anthropic_test.go @@ -459,32 +459,43 @@ func TestBuildAnthropicMessages_LegacyImages(t *testing.T) { func TestResolveThinking(t *testing.T) { t.Parallel() + on := true + off := false tests := []struct { + name string mode string budget int + enable *bool want *AnthropicThinking }{ - {"adaptive", 0, &AnthropicThinking{Type: "adaptive"}}, - {"disabled", 0, &AnthropicThinking{Type: "disabled"}}, - {"enabled", 1000, &AnthropicThinking{Type: "enabled", BudgetTokens: 1000}}, - {"", 500, &AnthropicThinking{Type: "enabled", BudgetTokens: 500}}, - {"", 0, nil}, + {"adaptive", "adaptive", 0, nil, &AnthropicThinking{Type: "adaptive"}}, + {"disabled", "disabled", 0, nil, &AnthropicThinking{Type: "disabled"}}, + {"enabled", "enabled", 1000, nil, &AnthropicThinking{Type: "enabled", BudgetTokens: 1000}}, + {"budget_legacy", "", 500, nil, &AnthropicThinking{Type: "enabled", BudgetTokens: 500}}, + {"unset", "", 0, nil, nil}, + {"thinking_enabled_true", "", 0, &on, &AnthropicThinking{Type: "adaptive"}}, + {"thinking_enabled_false", "", 0, &off, &AnthropicThinking{Type: "disabled"}}, + {"mode_wins_over_toggle", "disabled", 0, &on, &AnthropicThinking{Type: "disabled"}}, } for _, tt := range tests { - got := ResolveThinking(core.ChatOptions{ThinkingMode: tt.mode, ThinkingBudgetTokens: tt.budget}) - if tt.want == nil { - if got != nil { - t.Errorf("ResolveThinking(%q,%d) = %v, want nil", tt.mode, tt.budget, got) + t.Run(tt.name, func(t *testing.T) { + got := ResolveThinking(core.ChatOptions{ + ThinkingMode: tt.mode, ThinkingBudgetTokens: tt.budget, ThinkingEnabled: tt.enable, + }) + if tt.want == nil { + if got != nil { + t.Errorf("ResolveThinking = %v, want nil", got) + } + return } - continue - } - if got == nil { - t.Errorf("ResolveThinking(%q,%d) = nil, want %+v", tt.mode, tt.budget, *tt.want) - continue - } - if got.Type != tt.want.Type || got.BudgetTokens != tt.want.BudgetTokens { - t.Errorf("ResolveThinking(%q,%d) = %+v, want %+v", tt.mode, tt.budget, *got, *tt.want) - } + if got == nil { + t.Errorf("ResolveThinking = nil, want %+v", *tt.want) + return + } + if got.Type != tt.want.Type || got.BudgetTokens != tt.want.BudgetTokens { + t.Errorf("ResolveThinking = %+v, want %+v", *got, *tt.want) + } + }) } } diff --git a/client/adapters/compat.go b/client/adapters/compat.go index 56c8b8c8..5a060520 100644 --- a/client/adapters/compat.go +++ b/client/adapters/compat.go @@ -12,7 +12,7 @@ type OpenAICompatConfig struct { RequiresToolResultName bool `json:"requires_tool_result_name,omitempty"` RequiresAssistantAfterToolResult bool `json:"requires_assistant_after_tool_result,omitempty"` RequiresThinkingAsText bool `json:"requires_thinking_as_text,omitempty"` - ThinkingFormat string `json:"thinking_format,omitempty"` // "openai", "zai", "qwen", "openrouter" + ThinkingFormat string `json:"thinking_format,omitempty"` // "zai","longcat","kimi","deepseek","xiaomi","minimax","agnes","qwen","openrouter" // StripReasoningFromInput instructs buildRequestBase to omit the reasoning_content // field from assistant messages. DeepSeek (and compatible providers) return HTTP 400 // if reasoning_content appears in the input context of a multi-turn conversation. @@ -21,6 +21,17 @@ type OpenAICompatConfig struct { // core.ChatOptions.KimiContextCacheID is non-empty, buildRequestBase prepends a // {"role":"cache","content":} message per the MoonshotAI-Cookbook spec. SupportsCacheRole bool `json:"supports_cache_role,omitempty"` + // OmitMaxTokens instructs buildRequestBase to leave max_tokens unset instead + // of sending the default 4096. Providers that pre-authorize the maximum token + // cost (e.g. Agnes AI) can return insufficient_user_quota when that hold + // exceeds the account balance; omitting max_tokens lets the provider apply its + // own default, which avoids the oversized hold. + OmitMaxTokens bool `json:"omit_max_tokens,omitempty"` + // DefaultDisableThinking: when ThinkingEnabled is nil, emit an explicit + // thinking={"type":"disabled"} for formats that use the thinking object. + // LongCat enables thinking by default when the field is omitted; that often + // burns the entire max_tokens budget on reasoning_content with no reply. + DefaultDisableThinking bool `json:"default_disable_thinking,omitempty"` } // Per-provider compat configs. @@ -34,9 +45,36 @@ var ( MaxTokensField: "max_tokens", } OpenRouterCompat = OpenAICompatConfig{ - ThinkingFormat: "openrouter", MaxTokensField: "max_tokens", + // OpenRouter: reasoning={enabled|effort} per official reasoning-tokens guide. + ThinkingFormat: "openrouter", + MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, } + // AgnesCompat: Agnes AI is OpenAI-compatible only (chat completions). It does + // not honor OpenAI-specific features like store/developer-role/reasoning, + // so those are left disabled. It uses the standard max_tokens field. + // Agnes pre-authorizes the maximum token cost of every request; sending the + // default 4096 max_tokens makes that hold exceed the account balance and + // triggers an insufficient_user_quota (403). OmitMaxTokens leaves max_tokens + // unset so Agnes applies its own default, which keeps the hold small — this + // matches the behavior of a bare curl to the Agnes API. + // Thinking uses chat_template_kwargs.enable_thinking per Agnes OpenAI docs. + AgnesCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + SupportsUsageInStreaming: true, + OmitMaxTokens: true, + ThinkingFormat: "agnes", + } + // LongCatCompat: OpenAI-compatible only (https://api.longcat.chat/openai). + // Official docs: max_tokens, thinking={"type":"enabled"|"disabled"}, tools. + // DefaultDisableThinking is required: omitting thinking leaves LongCat's + // server-side default (enabled), which commonly yields reasoning-only replies. + LongCatCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + SupportsUsageInStreaming: true, + ThinkingFormat: "longcat", + DefaultDisableThinking: true, + } GeminiCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, } @@ -53,8 +91,9 @@ var ( OpenCodeGoCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, - ThinkingFormat: "openrouter", - StripReasoningFromInput: true, + // OpenCode Go routes many models through OpenRouter-style reasoning. + ThinkingFormat: "openrouter", + StripReasoningFromInput: true, } PoolsideCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", @@ -68,9 +107,17 @@ var ( KimiCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsCacheRole: true, + // Kimi K2.5/K2.6: thinking={"type":"enabled"|"disabled"} (defaults enabled). + // https://platform.kimi.ai/docs/guide/use-kimi-k2-thinking-model + ThinkingFormat: "kimi", + DefaultDisableThinking: true, } XiaomiCompat = OpenAICompatConfig{ MaxTokensField: "max_completion_tokens", + // Xiaomi MiMo: thinking={"type":"enabled"|"disabled"} (on by default for pro). + // https://mimo.mi.com/docs/en-US/quick-start/usage-guide/text-generation/deep-thinking + ThinkingFormat: "xiaomi", + DefaultDisableThinking: true, } AzureCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", @@ -82,17 +129,30 @@ var ( MaxTokensField: "max_tokens", } // DeepSeekCompat: OpenAI-compatible with usage in streaming. - // The provider rejects reasoning_content in input messages with HTTP 400, so we strip it. + // The provider rejects reasoning_content in input messages with HTTP 400, so we strip it + // for non-tool turns. Thinking mode: thinking={"type":...} (defaults enabled per + // https://api-docs.deepseek.com/guides/thinking_mode). DeepSeekCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, StripReasoningFromInput: true, + ThinkingFormat: "deepseek", + DefaultDisableThinking: true, } // ConcentrateCompat: OpenAI-compatible with usage in streaming. ConcentrateCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, } + // MiniMaxCompat: OpenAI chat completions at api.minimax.io/v1. + // Official OpenAI path: thinking.type is "disabled" | "adaptive" (default on when omitted). + // https://platform.minimax.io/docs/api-reference/text-openai-api + MiniMaxCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + SupportsUsageInStreaming: true, + ThinkingFormat: "minimax", + DefaultDisableThinking: true, + } ) func init() { @@ -102,6 +162,14 @@ func init() { DynamicMu.Lock() defer DynamicMu.Unlock() + if p, ok := OpenAICompatibleProviders["agnes"]; ok { + p.Compat = &AgnesCompat + OpenAICompatibleProviders["agnes"] = p + } + if p, ok := OpenAICompatibleProviders["longcat"]; ok { + p.Compat = &LongCatCompat + OpenAICompatibleProviders["longcat"] = p + } if p, ok := OpenAICompatibleProviders["grok"]; ok { p.Compat = &GrokCompat OpenAICompatibleProviders["grok"] = p @@ -162,6 +230,12 @@ func init() { p.Compat = &ConcentrateCompat OpenAICompatibleProviders["concentrate"] = p } + for _, id := range []string{"minimax_payg", "minimax_token_plan"} { + if p, ok := OpenAICompatibleProviders[id]; ok { + p.Compat = &MiniMaxCompat + OpenAICompatibleProviders[id] = p + } + } if p, ok := CoreProviders["openai"]; ok { p.Compat = &OpenAICompat CoreProviders["openai"] = p diff --git a/client/adapters/concentrate.go b/client/adapters/concentrate.go deleted file mode 100644 index cdafe4c7..00000000 --- a/client/adapters/concentrate.go +++ /dev/null @@ -1,59 +0,0 @@ -package adapters - -import ( - "context" - "log/slog" - "strings" - - "github.com/GrayCodeAI/eyrie/catalog/concentrate" - "github.com/GrayCodeAI/eyrie/client/core" -) - -// ConcentrateClient routes Concentrate AI models through OpenAIClient and -// AnthropicClient, selecting protocol per model based on the owned_by field. -// Concentrate exposes both /v1/chat/completions and /v1/messages endpoints. -type ConcentrateClient struct { - router ProtocolRouter - logger *slog.Logger -} - -// NewConcentrateClient builds a Concentrate AI provider client. -// baseURL is typically "https://api.concentrate.ai/v1". -func NewConcentrateClient(apiKey, baseURL string, compat *OpenAICompatConfig, opts ...core.ClientOption) *ConcentrateClient { - baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") - cOpts := append(append([]core.ClientOption{}, opts...), core.WithProviderName("concentrate")) - return &ConcentrateClient{ - router: ProtocolRouter{ - OpenAI: NewOpenAIClient(apiKey, baseURL, compat, cOpts...), - Anthropic: NewAnthropicClient(apiKey, AnthropicBaseFromOpenAIV1(baseURL), cOpts...), - }, - logger: slog.Default(), - } -} - -func (c *ConcentrateClient) Name() string { return "concentrate" } - -func (c *ConcentrateClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { - if concentrate.UsesMessagesAPI(opts.Model) { - return c.router.Chat(ctx, messages, opts, ChatProtocolMessages, nil) - } - return c.router.OpenAI.Chat(ctx, messages, opts) -} - -func (c *ConcentrateClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { - if concentrate.UsesMessagesAPI(opts.Model) { - return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ - Primary: ChatProtocolMessages, - }) - } - return c.router.OpenAI.StreamChat(ctx, messages, opts) -} - -func (c *ConcentrateClient) Ping(ctx context.Context) error { - if err := c.router.OpenAI.Ping(ctx); err == nil { - return nil - } - return c.router.Anthropic.Ping(ctx) -} - -var _ core.Provider = (*ConcentrateClient)(nil) diff --git a/client/adapters/concentrate_responses.go b/client/adapters/concentrate_responses.go index 180629d4..83cb4b13 100644 --- a/client/adapters/concentrate_responses.go +++ b/client/adapters/concentrate_responses.go @@ -1,6 +1,7 @@ package adapters import ( + "bufio" "bytes" "context" "encoding/json" @@ -57,11 +58,44 @@ type responsesRequest struct { Reasoning *responsesReasoning `json:"reasoning,omitempty"` Tools []responsesTool `json:"tools,omitempty"` ToolChoice interface{} `json:"tool_choice,omitempty"` - ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` PreviousResponseID string `json:"previous_response_id,omitempty"` PromptCacheKey string `json:"prompt_cache_key,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Text *responsesTextConfig `json:"text,omitempty"` + Store *bool `json:"store,omitempty"` + // Extended parameters from Concentrate Responses API + Include []string `json:"include,omitempty"` + Routing *routingConfig `json:"routing,omitempty"` + MaxToolCalls *int `json:"max_tool_calls,omitempty"` + ContextManagement []contextManagement `json:"context_management,omitempty"` + SafetyIdentifier *string `json:"safety_identifier,omitempty"` + Background *bool `json:"background,omitempty"` + Truncation *string `json:"truncation,omitempty"` + Conversation interface{} `json:"conversation,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + PromptCacheOptions *promptCacheOptions `json:"prompt_cache_options,omitempty"` + CacheControl *cacheControl `json:"cache_control,omitempty"` +} + +type promptCacheOptions struct { + Mode string `json:"mode,omitempty"` // "implicit" or "explicit" + TTL string `json:"ttl,omitempty"` // "5m", "30m", "1h" +} + +type cacheControl struct { + Type string `json:"type"` // "ephemeral" + TTL string `json:"ttl,omitempty"` +} + +type routingConfig struct { + Model interface{} `json:"model,omitempty"` + Strategy string `json:"strategy,omitempty"` + Metric string `json:"metric,omitempty"` +} + +type contextManagement struct { + Type string `json:"type"` } type responsesReasoning struct { @@ -70,27 +104,27 @@ type responsesReasoning struct { } type responsesTool struct { - Type string `json:"type"` - Name string `json:"name,omitempty"` - Function struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Parameters map[string]interface{} `json:"parameters,omitempty"` - } `json:"function,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters map[string]interface{} `json:"parameters"` + Strict bool `json:"strict,omitempty"` } type responsesTextConfig struct { - Format string `json:"format,omitempty"` - Verbosity string `json:"verbosity,omitempty"` + Format map[string]interface{} `json:"format"` } // responsesResponse is the response from the Concentrate Responses API. type responsesResponse struct { - ID string `json:"id"` - Object string `json:"object"` - Model string `json:"model"` - Output []outputItem `json:"output"` - Usage *responsesUsage `json:"usage,omitempty"` + ID string `json:"id"` + Object string `json:"object"` + Status string `json:"status"` + Model string `json:"model"` + Output []outputItem `json:"output"` + Usage *responsesUsage `json:"usage,omitempty"` + Error *responsesError `json:"error,omitempty"` + IncompleteDetails *responsesIncompleteDetail `json:"incomplete_details,omitempty"` } type outputItem struct { @@ -116,9 +150,21 @@ type responsesUsage struct { TotalTokens int `json:"total_tokens"` } +type responsesError struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +type responsesIncompleteDetail struct { + Reason string `json:"reason,omitempty"` +} + // Chat implements core.Provider using the Responses API. func (c *ConcentrateResponsesClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { - req := c.buildRequest(messages, opts, false) + req, err := c.buildRequest(messages, opts, false) + if err != nil { + return nil, err + } body, err := json.Marshal(req) if err != nil { @@ -155,15 +201,20 @@ func (c *ConcentrateResponsesClient) Chat(ctx context.Context, messages []core.E // StreamChat implements core.Provider using the Responses API with SSE streaming. func (c *ConcentrateResponsesClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { - req := c.buildRequest(messages, opts, true) + req, err := c.buildRequest(messages, opts, true) + if err != nil { + return nil, err + } body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("concentrate: marshal request: %w", err) } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/responses", bytes.NewReader(body)) + streamCtx, cancel := context.WithCancel(ctx) + httpReq, err := http.NewRequestWithContext(streamCtx, http.MethodPost, c.baseURL+"/responses", bytes.NewReader(body)) if err != nil { + cancel() return nil, fmt.Errorf("concentrate: create request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") @@ -173,16 +224,18 @@ func (c *ConcentrateResponsesClient) StreamChat(ctx context.Context, messages [] resp, err := c.httpClient.Do(httpReq) if err != nil { + cancel() return nil, fmt.Errorf("concentrate: request failed: %w", err) } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) resp.Body.Close() + cancel() return nil, fmt.Errorf("concentrate: stream request failed (%d): %s", resp.StatusCode, string(body)) } - return c.handleStream(ctx, resp), nil + return c.handleStream(streamCtx, cancel, resp), nil } // Ping checks the health of the Concentrate API. @@ -209,13 +262,14 @@ func (c *ConcentrateResponsesClient) Ping(ctx context.Context) error { return nil } -func (c *ConcentrateResponsesClient) buildRequest(messages []core.EyrieMessage, opts core.ChatOptions, stream bool) responsesRequest { +func (c *ConcentrateResponsesClient) buildRequest(messages []core.EyrieMessage, opts core.ChatOptions, stream bool) (responsesRequest, error) { req := responsesRequest{ - Model: opts.Model, - Input: c.messagesToInput(messages), - MaxOutputTokens: opts.MaxTokens, - Stream: stream, - ParallelToolCalls: true, + Model: opts.Model, + Input: c.messagesToInput(messages), + MaxOutputTokens: opts.MaxTokens, + Stream: stream, + Metadata: opts.Metadata, + Store: opts.Store, } if opts.System != "" { @@ -241,43 +295,155 @@ func (c *ConcentrateResponsesClient) buildRequest(messages []core.EyrieMessage, if len(opts.Tools) > 0 { req.Tools = make([]responsesTool, 0, len(opts.Tools)) for _, tool := range opts.Tools { - t := responsesTool{ - Type: "function", + req.Tools = append(req.Tools, responsesTool{ + Type: "function", + Name: tool.Name, + Description: tool.Description, + Parameters: normalizeToolParams(tool.Parameters), + Strict: true, + }) + } + parallel := true + if opts.ToolChoice != nil && opts.ToolChoice.DisableParallelToolUse { + parallel = false + } + req.ParallelToolCalls = ¶llel + req.ToolChoice = concentrateToolChoice(opts.ToolChoice) + } + + if opts.ResponseFormat != nil { + format := map[string]interface{}{"type": opts.ResponseFormat.Type} + if opts.ResponseFormat.Type == "json_schema" { + if strings.TrimSpace(opts.ResponseFormat.Schema) == "" { + return responsesRequest{}, fmt.Errorf("concentrate: json_schema response format requires a schema") } - t.Function.Name = tool.Name - t.Function.Description = tool.Description - t.Function.Parameters = tool.Parameters - req.Tools = append(req.Tools, t) + var schema map[string]interface{} + if err := json.Unmarshal([]byte(opts.ResponseFormat.Schema), &schema); err != nil { + return responsesRequest{}, fmt.Errorf("concentrate: invalid response schema: %w", err) + } + format["name"] = "hawk_response" + format["schema"] = schema } + req.Text = &responsesTextConfig{Format: format} + } + + // Map extended Responses API parameters + if opts.ServiceTier != "" { + req.ServiceTier = opts.ServiceTier } - // Map text format for structured output - if opts.ResponseFormat != nil && opts.ResponseFormat.Type == "json_schema" { - req.Text = &responsesTextConfig{ - Format: "json_schema", + return req, nil +} + +func concentrateToolChoice(choice *core.ToolChoiceOption) interface{} { + if choice == nil { + return nil + } + switch choice.Type { + case "any": + return "required" + case "tool": + if choice.Name != "" { + return map[string]interface{}{"type": "function", "name": choice.Name} } + return "required" + default: + return choice.Type } +} - return req +// normalizeToolParams ensures tool parameters conform to Concentrate's strict mode +// requirements: additionalProperties must be false at the top level when strict=true. +// See: https://concentrate.ai/docs/api-reference/endpoint/tool-calling +func normalizeToolParams(params map[string]interface{}) map[string]interface{} { + if params == nil { + return nil + } + // Only enforce for object-typed schemas + if t, ok := params["type"]; ok && t == "object" { + if _, has := params["additionalProperties"]; !has { + params["additionalProperties"] = false + } + } + return params } func (c *ConcentrateResponsesClient) messagesToInput(messages []core.EyrieMessage) []map[string]interface{} { input := make([]map[string]interface{}, 0, len(messages)) for _, msg := range messages { - item := map[string]interface{}{ - "role": msg.Role, + if len(msg.ToolResults) > 0 { + for _, result := range msg.ToolResults { + item := map[string]interface{}{ + "type": "function_call_output", + "call_id": result.ToolUseID, + "output": result.Content, + } + if result.IsError { + item["is_error"] = true + } + input = append(input, item) + } + continue } - if msg.Content != "" { - item["content"] = msg.Content + + if msg.Content != "" || len(msg.ContentParts) > 0 || len(msg.Images) > 0 { + item := map[string]interface{}{"role": msg.Role} + switch { + case len(msg.ContentParts) > 0: + item["content"] = concentrateContentParts(msg.ContentParts) + case len(msg.Images) > 0: + parts := make([]map[string]interface{}, 0, len(msg.Images)+1) + if msg.Content != "" { + parts = append(parts, map[string]interface{}{"type": "input_text", "text": msg.Content}) + } + for _, image := range msg.Images { + parts = append(parts, map[string]interface{}{"type": "input_image", "image_url": core.OpenAIImageURL(image)}) + } + item["content"] = parts + default: + item["content"] = msg.Content + } + input = append(input, item) + } + + for _, call := range msg.ToolUse { + arguments, _ := json.Marshal(call.Arguments) + input = append(input, map[string]interface{}{ + "type": "function_call", + "call_id": call.ID, + "name": call.Name, + "arguments": string(arguments), + }) } - input = append(input, item) } return input } +func concentrateContentParts(parts []core.ContentPart) []map[string]interface{} { + content := make([]map[string]interface{}, 0, len(parts)) + for _, part := range parts { + switch part.Type { + case "text": + content = append(content, map[string]interface{}{"type": "input_text", "text": part.Text}) + case "image_url": + if part.ImageURL == nil { + continue + } + item := map[string]interface{}{"type": "input_image", "image_url": part.ImageURL.URL} + if part.ImageURL.Detail != "" { + item["detail"] = part.ImageURL.Detail + } + content = append(content, item) + } + } + return content +} + func (c *ConcentrateResponsesClient) toEyrieResponse(resp responsesResponse) *core.EyrieResponse { eyrieResp := &core.EyrieResponse{ - Content: c.extractOutputText(resp.Output), + Content: c.extractOutputText(resp.Output), + FinishReason: concentrateFinishReason(resp), + RequestID: resp.ID, } if resp.Usage != nil { @@ -296,7 +462,7 @@ func (c *ConcentrateResponsesClient) toEyrieResponse(resp responsesResponse) *co _ = json.Unmarshal([]byte(item.Arguments), &args) } eyrieResp.ToolCalls = append(eyrieResp.ToolCalls, core.ToolCall{ - ID: item.ID, + ID: item.CallID, Name: item.Name, Arguments: args, }) @@ -311,7 +477,7 @@ func (c *ConcentrateResponsesClient) extractOutputText(output []outputItem) stri for _, item := range output { if item.Type == "message" { for _, content := range item.Content { - if content.Type == "text" { + if content.Type == "output_text" { text.WriteString(content.Text) } } @@ -320,30 +486,53 @@ func (c *ConcentrateResponsesClient) extractOutputText(output []outputItem) stri return text.String() } +func concentrateFinishReason(resp responsesResponse) string { + if resp.IncompleteDetails != nil && resp.IncompleteDetails.Reason != "" { + return resp.IncompleteDetails.Reason + } + for _, item := range resp.Output { + if item.Type == "function_call" { + return "tool_calls" + } + } + if resp.Status == "failed" { + return "error" + } + return "stop" +} + // streamEvent represents a single SSE event from the Responses API. type streamEvent struct { Type string `json:"type"` SequenceNumber int `json:"sequence_number,omitempty"` Delta string `json:"delta,omitempty"` + Arguments string `json:"arguments,omitempty"` + Name string `json:"name,omitempty"` + CallID string `json:"call_id,omitempty"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` Item json.RawMessage `json:"item,omitempty"` Response json.RawMessage `json:"response,omitempty"` OutputIndex int `json:"output_index,omitempty"` ContentIndex int `json:"content_index,omitempty"` } -func (c *ConcentrateResponsesClient) handleStream(ctx context.Context, resp *http.Response) *core.StreamResult { +func (c *ConcentrateResponsesClient) handleStream(ctx context.Context, cancel context.CancelFunc, resp *http.Response) *core.StreamResult { events := make(chan core.EyrieStreamEvent, core.StreamChannelBuffer) go func() { defer close(events) defer resp.Body.Close() + defer cancel() reader := newSSEReader(resp.Body) + toolCalls := map[int]outputItem{} + emittedToolCall := false for { event, err := reader.Read() if err != nil { if err != io.EOF { - events <- core.EyrieStreamEvent{Type: "error", Error: err.Error()} + sendConcentrateStreamEvent(ctx, events, core.EyrieStreamEvent{Type: "error", Error: err.Error()}) } return } @@ -351,89 +540,170 @@ func (c *ConcentrateResponsesClient) handleStream(ctx context.Context, resp *htt switch event.Type { case "response.output_text.delta": if event.Delta != "" { - events <- core.EyrieStreamEvent{Type: "content", Content: event.Delta} + if !sendConcentrateStreamEvent(ctx, events, core.EyrieStreamEvent{Type: "content", Content: event.Delta}) { + return + } } - case "response.reasoning_text.delta": + case "response.reasoning_text.delta", "response.reasoning_summary_text.delta": if event.Delta != "" { - events <- core.EyrieStreamEvent{Type: "thinking", Thinking: event.Delta} + if !sendConcentrateStreamEvent(ctx, events, core.EyrieStreamEvent{Type: "thinking", Thinking: event.Delta}) { + return + } + } + case "response.output_item.added": + var item outputItem + if json.Unmarshal(event.Item, &item) == nil && item.Type == "function_call" { + toolCalls[event.OutputIndex] = item } case "response.function_call_arguments.delta": - if event.Delta != "" { - events <- core.EyrieStreamEvent{Type: "tool_call_delta", Content: event.Delta} + item := toolCalls[event.OutputIndex] + if event.CallID != "" { + item.CallID = event.CallID + } + item.Arguments += event.Delta + toolCalls[event.OutputIndex] = item + case "response.function_call_arguments.done": + item := toolCalls[event.OutputIndex] + if event.CallID != "" { + item.CallID = event.CallID + } + if event.Name != "" { + item.Name = event.Name + } + if event.Arguments != "" { + item.Arguments = event.Arguments + } + var args map[string]interface{} + if item.Arguments != "" { + _ = json.Unmarshal([]byte(item.Arguments), &args) + } + emittedToolCall = true + if !sendConcentrateStreamEvent(ctx, events, core.EyrieStreamEvent{ + Type: "tool_call", + ToolCall: &core.ToolCall{ + ID: item.CallID, + Name: item.Name, + Arguments: args, + }, + }) { + return } case "response.completed": var r responsesResponse if err := json.Unmarshal(event.Response, &r); err == nil && r.Usage != nil { - events <- core.EyrieStreamEvent{Type: "usage", Usage: &core.EyrieUsage{ + if !sendConcentrateStreamEvent(ctx, events, core.EyrieStreamEvent{Type: "usage", Usage: &core.EyrieUsage{ PromptTokens: r.Usage.InputTokens, CompletionTokens: r.Usage.OutputTokens, TotalTokens: r.Usage.TotalTokens, - }} + }}) { + return + } } - events <- core.EyrieStreamEvent{Type: "done", StopReason: "stop"} + stopReason := "stop" + if emittedToolCall { + stopReason = "tool_calls" + } + sendConcentrateStreamEvent(ctx, events, core.EyrieStreamEvent{Type: "done", StopReason: stopReason}) + return + case "response.incomplete": + var r responsesResponse + _ = json.Unmarshal(event.Response, &r) + if r.Usage != nil { + if !sendConcentrateStreamEvent(ctx, events, core.EyrieStreamEvent{Type: "usage", Usage: &core.EyrieUsage{ + PromptTokens: r.Usage.InputTokens, + CompletionTokens: r.Usage.OutputTokens, + TotalTokens: r.Usage.TotalTokens, + }}) { + return + } + } + sendConcentrateStreamEvent(ctx, events, core.EyrieStreamEvent{Type: "done", StopReason: concentrateFinishReason(r)}) return case "response.failed": - events <- core.EyrieStreamEvent{Type: "error", Error: "response failed"} + var r responsesResponse + _ = json.Unmarshal(event.Response, &r) + message := "response failed" + if r.Error != nil && r.Error.Message != "" { + message = r.Error.Message + } + sendConcentrateStreamEvent(ctx, events, core.EyrieStreamEvent{Type: "error", Error: message}) return case "error": - events <- core.EyrieStreamEvent{Type: "error", Error: "stream error"} + message := event.Message + if message == "" { + message = "stream error" + } + sendConcentrateStreamEvent(ctx, events, core.EyrieStreamEvent{Type: "error", Error: message}) return } } }() - return llm.NewStreamResult(events, "", func() {}) + return llm.NewStreamResult(events, "", cancel) +} + +func sendConcentrateStreamEvent(ctx context.Context, events chan<- core.EyrieStreamEvent, event core.EyrieStreamEvent) bool { + select { + case events <- event: + return true + case <-ctx.Done(): + return false + } } // sseReader reads SSE events from a stream. type sseReader struct { - buf []byte - reader io.Reader + reader *bufio.Reader } func newSSEReader(r io.Reader) *sseReader { - return &sseReader{reader: r} + return &sseReader{reader: bufio.NewReader(r)} } func (s *sseReader) Read() (streamEvent, error) { var event streamEvent - var data strings.Builder - - buf := make([]byte, 4096) + var data []string for { - n, err := s.reader.Read(buf) + line, err := s.reader.ReadString('\n') if err != nil { - return event, err - } - s.buf = append(s.buf, buf[:n]...) - - // Process complete SSE events (separated by double newline) - for { - idx := bytes.Index(s.buf, []byte("\n\n")) - if idx == -1 { - break + if err != io.EOF { + return event, err } - - block := string(s.buf[:idx]) - s.buf = s.buf[idx+2:] - - lines := strings.Split(block, "\n") - for _, line := range lines { - if strings.HasPrefix(line, "data: ") { - data.WriteString(strings.TrimPrefix(line, "data: ")) + if len(line) == 0 { + if len(data) == 0 { + return event, io.EOF } + return decodeConcentrateSSEData(data) } - - if data.Len() > 0 { - if err := json.Unmarshal([]byte(data.String()), &event); err != nil { - return event, fmt.Errorf("concentrate: unmarshal stream event: %w", err) + } + line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") + if line == "" || err == io.EOF { + if len(data) == 0 { + if err == io.EOF { + return event, io.EOF } - return event, nil + continue } + return decodeConcentrateSSEData(data) + } + if strings.HasPrefix(line, "data:") { + data = append(data, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) } } } +func decodeConcentrateSSEData(data []string) (streamEvent, error) { + payload := strings.Join(data, "\n") + if payload == "[DONE]" { + return streamEvent{Type: "response.completed"}, nil + } + var event streamEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + return event, fmt.Errorf("concentrate: unmarshal stream event: %w", err) + } + return event, nil +} + // Ensure ConcentrateResponsesClient implements core.Provider. var _ core.Provider = (*ConcentrateResponsesClient)(nil) diff --git a/client/adapters/concentrate_responses_test.go b/client/adapters/concentrate_responses_test.go index bb991416..c98148a8 100644 --- a/client/adapters/concentrate_responses_test.go +++ b/client/adapters/concentrate_responses_test.go @@ -2,119 +2,312 @@ package adapters import ( "context" + "encoding/json" + "io" "net/http" + "strings" "testing" + "time" "github.com/GrayCodeAI/eyrie/client/core" ) func TestNewConcentrateResponsesClient(t *testing.T) { t.Parallel() - client := NewConcentrateResponsesClient("cn-key", "https://api.concentrate.ai/v1") + client := NewConcentrateResponsesClient("cn-key", "https://api.concentrate.ai/v1/") if client == nil { t.Fatal("NewConcentrateResponsesClient returned nil") } if client.Name() != "concentrate" { - t.Errorf("expected name 'concentrate', got %q", client.Name()) + t.Errorf("provider name = %q, want concentrate", client.Name()) + } + if client.BaseURL() != "https://api.concentrate.ai/v1" { + t.Errorf("base URL = %q", client.BaseURL()) } } -func TestConcentrateResponsesClient_Chat(t *testing.T) { +func TestConcentrateResponsesClient_ChatUsesResponsesContract(t *testing.T) { t.Parallel() transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodPost || req.URL.Path != "/v1/responses" { + t.Fatalf("request = %s %s", req.Method, req.URL.Path) + } + if got := req.Header.Get("Authorization"); got != "Bearer cn-key" { + t.Fatalf("Authorization = %q", got) + } + + var body map[string]interface{} + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatal(err) + } + tools := body["tools"].([]interface{}) + tool := tools[0].(map[string]interface{}) + if tool["name"] != "read_file" || tool["type"] != "function" { + t.Fatalf("tool = %#v", tool) + } + if _, legacy := tool["function"]; legacy { + t.Fatalf("Responses API tool contains Chat Completions wrapper: %#v", tool) + } + return jsonResponse(http.StatusOK, map[string]any{ "id": "resp_123", "object": "response", + "status": "completed", "model": "gpt-5", - "output": []map[string]any{ - { - "type": "message", - "role": "assistant", - "content": []map[string]any{{"type": "text", "text": "Hello from Responses API!"}}, - }, - }, + "output": []map[string]any{{ + "type": "message", + "role": "assistant", + "content": []map[string]any{{ + "type": "output_text", + "text": "Hello from Responses API!", + }}, + }}, "usage": map[string]int{"input_tokens": 10, "output_tokens": 20, "total_tokens": 30}, }), nil }) client := NewConcentrateResponsesClient("cn-key", "https://api.concentrate.ai/v1") client.httpClient = &http.Client{Transport: transport} - - resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "gpt-5", MaxTokens: 256}) + resp, err := client.Chat( + context.Background(), + []core.EyrieMessage{{Role: "user", Content: "Hi"}}, + core.ChatOptions{ + Model: "gpt-5", + Tools: []core.EyrieTool{{ + Name: "read_file", Description: "Read a file", + Parameters: map[string]interface{}{"type": "object"}, + }}, + }, + ) if err != nil { t.Fatalf("Chat: %v", err) } if resp.Content != "Hello from Responses API!" { - t.Errorf("content = %q, want Hello from Responses API!", resp.Content) + t.Errorf("content = %q", resp.Content) } - if resp.Usage == nil { - t.Fatal("expected usage") + if resp.RequestID != "resp_123" || resp.FinishReason != "stop" { + t.Errorf("response metadata = %+v", resp) } - if resp.Usage.PromptTokens != 10 || resp.Usage.CompletionTokens != 20 { - t.Errorf("usage = %+v, want prompt=10, completion=20", resp.Usage) + if resp.Usage == nil || resp.Usage.PromptTokens != 10 || resp.Usage.CompletionTokens != 20 { + t.Errorf("usage = %+v", resp.Usage) } } -func TestConcentrateResponsesClient_ChatWithTools(t *testing.T) { +func TestConcentrateResponsesClient_BuildRequestPreservesToolTurn(t *testing.T) { t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + client := NewConcentrateResponsesClient("cn-key", "https://api.concentrate.ai/v1") + req, err := client.buildRequest([]core.EyrieMessage{ + {Role: "user", Content: "Read main.go"}, + {Role: "assistant", ToolUse: []core.ToolCall{{ + ID: "call_1", Name: "read_file", Arguments: map[string]interface{}{"path": "main.go"}, + }}}, + {Role: "user", ToolResults: []core.ToolResult{{ + ToolUseID: "call_1", Content: "package main", IsError: true, + }}}, + }, core.ChatOptions{ + Model: "gpt-5", + Tools: []core.EyrieTool{{ + Name: "read_file", Parameters: map[string]interface{}{"type": "object"}, + }}, + ToolChoice: &core.ToolChoiceOption{Type: "tool", Name: "read_file", DisableParallelToolUse: true}, + }, false) + if err != nil { + t.Fatal(err) + } + + input := req.Input.([]map[string]interface{}) + if got := input[1]["type"]; got != "function_call" { + t.Fatalf("assistant tool item type = %v", got) + } + if got := input[1]["call_id"]; got != "call_1" { + t.Fatalf("assistant call_id = %v", got) + } + if got := input[2]["type"]; got != "function_call_output" { + t.Fatalf("tool result type = %v", got) + } + if got := input[2]["is_error"]; got != true { + t.Fatalf("tool result is_error = %v", got) + } + if req.ParallelToolCalls == nil || *req.ParallelToolCalls { + t.Fatal("parallel_tool_calls should be explicitly false") + } + choice := req.ToolChoice.(map[string]interface{}) + if choice["type"] != "function" || choice["name"] != "read_file" { + t.Fatalf("tool_choice = %#v", choice) + } +} + +func TestConcentrateResponsesClient_StructuredOutputUsesTextFormat(t *testing.T) { + t.Parallel() + client := NewConcentrateResponsesClient("cn-key", "https://api.concentrate.ai/v1") + req, err := client.buildRequest(nil, core.ChatOptions{ + Model: "gpt-5", + ResponseFormat: &core.ResponseFormat{ + Type: "json_schema", + Schema: `{"type":"object","properties":{"answer":{"type":"string"}}}`, + }, + }, false) + if err != nil { + t.Fatal(err) + } + if req.Text == nil || req.Text.Format["type"] != "json_schema" { + t.Fatalf("text format = %#v", req.Text) + } + if req.Text.Format["name"] != "hawk_response" { + t.Fatalf("schema name = %#v", req.Text.Format["name"]) + } + if _, ok := req.Text.Format["schema"].(map[string]interface{}); !ok { + t.Fatalf("schema = %#v", req.Text.Format["schema"]) + } +} + +func TestConcentrateResponsesClient_InvalidSchemaFailsBeforeRequest(t *testing.T) { + t.Parallel() + client := NewConcentrateResponsesClient("cn-key", "https://api.concentrate.ai/v1") + _, err := client.buildRequest(nil, core.ChatOptions{ + Model: "gpt-5", + ResponseFormat: &core.ResponseFormat{Type: "json_schema", Schema: "not-json"}, + }, false) + if err == nil || !strings.Contains(err.Error(), "invalid response schema") { + t.Fatalf("error = %v", err) + } +} + +func TestConcentrateResponsesClient_ChatToolCallUsesCallID(t *testing.T) { + t.Parallel() + transport := roundTripFunc(func(*http.Request) (*http.Response, error) { return jsonResponse(http.StatusOK, map[string]any{ "id": "resp_456", - "object": "response", - "model": "gpt-5", - "output": []map[string]any{ - { - "type": "function_call", - "id": "call_1", - "name": "get_weather", - "arguments": `{"city":"NYC"}`, - }, - }, + "status": "completed", + "output": []map[string]any{{ + "type": "function_call", + "id": "item_1", + "call_id": "call_1", + "name": "get_weather", + "arguments": `{"city":"NYC"}`, + }}, }), nil }) - client := NewConcentrateResponsesClient("cn-key", "https://api.concentrate.ai/v1") client.httpClient = &http.Client{Transport: transport} - resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "What's the weather?"}}, core.ChatOptions{Model: "gpt-5", MaxTokens: 256}) + resp, err := client.Chat(context.Background(), nil, core.ChatOptions{Model: "gpt-5"}) if err != nil { - t.Fatalf("Chat: %v", err) + t.Fatal(err) } - if len(resp.ToolCalls) != 1 { - t.Fatalf("expected 1 tool call, got %d", len(resp.ToolCalls)) + if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].ID != "call_1" { + t.Fatalf("tool calls = %+v", resp.ToolCalls) } - if resp.ToolCalls[0].Name != "get_weather" { - t.Errorf("tool name = %q, want get_weather", resp.ToolCalls[0].Name) + if resp.FinishReason != "tool_calls" { + t.Fatalf("finish reason = %q", resp.FinishReason) } } -func TestConcentrateResponsesClient_Ping(t *testing.T) { +func TestConcentrateResponsesClient_StreamTextUsageAndDone(t *testing.T) { t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, map[string]any{"data": []any{}}), nil + stream := strings.Join([]string{ + `event: response.output_text.delta`, + `data: {"type":"response.output_text.delta","delta":"Hello"}`, + ``, + `event: response.completed`, + `data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}`, + ``, + }, "\r\n") + transport := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(stream)), + }, nil }) + client := NewConcentrateResponsesClient("cn-key", "https://api.concentrate.ai/v1") + client.httpClient = &http.Client{Transport: transport} + + result, err := client.StreamChat(context.Background(), nil, core.ChatOptions{Model: "gpt-5"}) + if err != nil { + t.Fatal(err) + } + defer result.Close() + var got []core.EyrieStreamEvent + timeout := time.After(2 * time.Second) + for { + select { + case event, ok := <-result.Events: + if !ok { + if len(got) != 3 { + t.Fatalf("events = %+v", got) + } + if got[0].Type != "content" || got[0].Content != "Hello" { + t.Fatalf("content event = %+v", got[0]) + } + if got[1].Type != "usage" || got[1].Usage.TotalTokens != 3 { + t.Fatalf("usage event = %+v", got[1]) + } + if got[2].Type != "done" || got[2].StopReason != "stop" { + t.Fatalf("done event = %+v", got[2]) + } + return + } + got = append(got, event) + case <-timeout: + t.Fatal("timed out waiting for stream") + } + } +} + +func TestConcentrateResponsesClient_StreamToolCall(t *testing.T) { + t.Parallel() + stream := strings.Join([]string{ + `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_7","name":"read_file"}}`, + ``, + `data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"path\":"}`, + ``, + `data: {"type":"response.function_call_arguments.done","output_index":0,"call_id":"call_7","name":"read_file","arguments":"{\"path\":\"main.go\"}"}`, + ``, + `data: {"type":"response.completed","response":{"status":"completed"}}`, + ``, + }, "\n") + transport := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(stream)), Header: http.Header{}}, nil + }) client := NewConcentrateResponsesClient("cn-key", "https://api.concentrate.ai/v1") client.httpClient = &http.Client{Transport: transport} - err := client.Ping(context.Background()) + result, err := client.StreamChat(context.Background(), nil, core.ChatOptions{Model: "gpt-5"}) if err != nil { - t.Fatalf("Ping: %v", err) + t.Fatal(err) + } + defer result.Close() + var got []core.EyrieStreamEvent + for event := range result.Events { + got = append(got, event) + } + if len(got) != 2 || got[0].Type != "tool_call" || got[0].ToolCall == nil { + t.Fatalf("events = %+v", got) + } + if got[0].ToolCall.ID != "call_7" || got[0].ToolCall.Name != "read_file" || got[0].ToolCall.Arguments["path"] != "main.go" { + t.Fatalf("tool call = %+v", got[0].ToolCall) + } + if got[1].Type != "done" || got[1].StopReason != "tool_calls" { + t.Fatalf("done = %+v", got[1]) } } -func TestConcentrateResponsesClient_PingNoAuth(t *testing.T) { +func TestConcentrateResponsesClient_PingDoesNotRequireAuth(t *testing.T) { t.Parallel() - // Ping should work without auth for Concentrate transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != "/v1/models" { + t.Fatalf("path = %q", req.URL.Path) + } + if got := req.Header.Get("Authorization"); got != "" { + t.Fatalf("public models request has Authorization = %q", got) + } return jsonResponse(http.StatusOK, map[string]any{"data": []any{}}), nil }) - client := NewConcentrateResponsesClient("", "https://api.concentrate.ai/v1") client.httpClient = &http.Client{Transport: transport} - - err := client.Ping(context.Background()) - if err != nil { - t.Fatalf("Ping without auth: %v", err) + if err := client.Ping(context.Background()); err != nil { + t.Fatal(err) } } diff --git a/client/adapters/concentrate_test.go b/client/adapters/concentrate_test.go deleted file mode 100644 index aad9c87b..00000000 --- a/client/adapters/concentrate_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package adapters - -import ( - "context" - "io" - "net/http" - "strings" - "testing" - - "github.com/GrayCodeAI/eyrie/client/core" - "github.com/GrayCodeAI/eyrie/types" -) - -func TestNewConcentrateClient_HasBothProtocols(t *testing.T) { - t.Parallel() - client := NewConcentrateClient("cn-key", "https://api.concentrate.ai/v1", nil) - if client == nil { - t.Fatal("NewConcentrateClient returned nil") - } - if client.Name() != "concentrate" { - t.Errorf("expected name 'concentrate', got %q", client.Name()) - } - if client.router.OpenAI == nil { - t.Fatal("expected OpenAI client") - } - if client.router.Anthropic == nil { - t.Fatal("expected Anthropic client") - } -} - -func TestConcentrateClient_ChatOpenAIProtocol(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, map[string]any{ - "id": "chatcmpl-1", - "object": "chat.completion", - "choices": []map[string]any{{"message": map[string]string{"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}}, - "usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}, - }), nil - }) - - client := NewConcentrateClient("cn-key", "https://api.concentrate.ai/v1", nil) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - - // Non-anthropic model uses OpenAI protocol - resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "gpt-5", MaxTokens: 256}) - if err != nil { - t.Fatalf("Chat: %v", err) - } - if resp.Content != "Hello!" { - t.Errorf("content = %q, want Hello!", resp.Content) - } -} - -func TestConcentrateClient_ChatAnthropicProtocol(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, map[string]any{ - "id": "msg_1", - "type": "message", - "role": "assistant", - "content": []map[string]string{{"type": "text", "text": "Hello from Anthropic!"}}, - "stop_reason": "end_turn", - "usage": map[string]int{"input_tokens": 1, "output_tokens": 2}, - }), nil - }) - - client := NewConcentrateClient("cn-key", "https://api.concentrate.ai/v1", nil) - client.router.Anthropic.httpClient = &http.Client{Transport: transport} - - // Anthropic model uses Messages API - resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "claude-opus-5", MaxTokens: 256}) - if err != nil { - t.Fatalf("Chat: %v", err) - } - if resp.Content != "Hello from Anthropic!" { - t.Errorf("content = %q, want Hello from Anthropic!", resp.Content) - } -} - -func TestConcentrateClient_StreamChatOpenAIProtocol(t *testing.T) { - t.Parallel() - // OpenAI streaming uses SSE format - body := "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"content\":\"Stream\"},\"index\":0}]}\n\ndata: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"content\":\" test\"},\"index\":0}]}\n\ndata: [DONE]\n\n" - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(body)), - }, nil - }) - - client := NewConcentrateClient("cn-key", "https://api.concentrate.ai/v1", nil) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - - result, err := client.StreamChat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "gpt-5", MaxTokens: 256}) - if err != nil { - t.Fatalf("StreamChat: %v", err) - } - defer result.Close() - - var content string - for event := range result.Events { - if event.Type == "content" { - content += event.Content - } - } - if content != "Stream test" { - t.Errorf("content = %q, want Stream test", content) - } -} - -func TestConcentrateClient_PingOpenAISuccess(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, map[string]string{"status": "ok"}), nil - }) - - client := NewConcentrateClient("cn-key", "https://api.concentrate.ai/v1", nil) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - - err := client.Ping(context.Background()) - if err != nil { - t.Fatalf("Ping: %v", err) - } -} - -func TestConcentrateClient_PingFallbackToAnthropic(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - // Fail OpenAI endpoint - if req.URL.Path == "/v1/models" && !strings.Contains(req.URL.String(), "anthropic") { - return jsonResponse(http.StatusServiceUnavailable, map[string]any{ - "error": map[string]string{"message": "service unavailable"}, - }), nil - } - return jsonResponse(http.StatusOK, map[string]string{"status": "ok"}), nil - }) - - client := NewConcentrateClient("cn-key", "https://api.concentrate.ai/v1", nil) - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - client.router.Anthropic.httpClient = &http.Client{Transport: transport} - - err := client.Ping(context.Background()) - if err != nil { - t.Fatalf("Ping fallback: %v", err) - } -} diff --git a/client/adapters/deepseek.go b/client/adapters/deepseek.go index 6e2691ce..e3209602 100644 --- a/client/adapters/deepseek.go +++ b/client/adapters/deepseek.go @@ -2,69 +2,37 @@ package adapters import ( "context" - "log/slog" "strings" "github.com/GrayCodeAI/eyrie/client/core" ) -// DeepSeekClient uses OpenAI-compatible DeepSeek endpoints first, -// with optional Anthropic-compat fallback if the OpenAI endpoint is down. +// DeepSeekClient uses the official OpenAI-compatible DeepSeek surface only +// (https://api.deepseek.com). type DeepSeekClient struct { - router ProtocolRouter - logger *slog.Logger + openai *OpenAIClient } -// NewDeepSeekClient builds a DeepSeek provider client. -// openAIBase is typically "https://api.deepseek.com/v1" -// anthropicBase is typically "https://api.deepseek.com/anthropic" -func NewDeepSeekClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *DeepSeekClient { +// NewDeepSeekClient builds an OpenAI-compatible DeepSeek client. +// openAIBase is typically "https://api.deepseek.com/v1". +func NewDeepSeekClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...core.ClientOption) *DeepSeekClient { openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") - anthropicBase = strings.TrimRight(strings.TrimSpace(anthropicBase), "/") dsOpts := append(append([]core.ClientOption{}, opts...), core.WithProviderName("deepseek")) - o := NewOpenAIClient(apiKey, openAIBase, compat, dsOpts...) - var a *AnthropicClient - if anthropicBase != "" { - a = NewAnthropicClient(apiKey, anthropicBase, dsOpts...) - } - return &DeepSeekClient{ - router: ProtocolRouter{OpenAI: o, Anthropic: a}, - logger: slog.Default(), - } + return &DeepSeekClient{openai: NewOpenAIClient(apiKey, openAIBase, compat, dsOpts...)} } func (c *DeepSeekClient) Name() string { return "deepseek" } func (c *DeepSeekClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { - return c.router.Chat(ctx, messages, opts, ChatProtocolCompletions, func(err error, _ *core.EyrieResponse) bool { - if err != nil && c.router.Anthropic != nil && core.IsRetriableError(err) { - c.logger.Info("DeepSeek: OpenAI endpoint failed; retrying via Anthropic compatibility", "error", err) - return true - } - return false - }) + return c.openai.Chat(ctx, messages, opts) } func (c *DeepSeekClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { - return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ - Primary: ChatProtocolCompletions, - FallbackOnError: func(err error) bool { - if c.router.Anthropic != nil && core.IsRetriableError(err) { - c.logger.Info("DeepSeek: OpenAI stream failed; retrying via Anthropic compatibility", "error", err) - return true - } - return false - }, - }) + return c.openai.StreamChat(ctx, messages, opts) } func (c *DeepSeekClient) Ping(ctx context.Context) error { - if err := c.router.OpenAI.Ping(ctx); err == nil { - return nil - } else if c.router.Anthropic == nil || !core.IsRetriableError(err) { - return err - } - return c.router.Anthropic.Ping(ctx) + return c.openai.Ping(ctx) } var _ core.Provider = (*DeepSeekClient)(nil) diff --git a/client/adapters/deepseek_test.go b/client/adapters/deepseek_test.go index 5e90a537..8f2c2197 100644 --- a/client/adapters/deepseek_test.go +++ b/client/adapters/deepseek_test.go @@ -2,245 +2,64 @@ package adapters import ( "context" + "encoding/json" "io" "net/http" + "net/http/httptest" "strings" "testing" + "time" "github.com/GrayCodeAI/eyrie/client/core" - "github.com/GrayCodeAI/eyrie/types" ) -func TestNewDeepSeekClient_WithAnthropicFallback(t *testing.T) { +func TestNewDeepSeekClient_OpenAIOnly(t *testing.T) { t.Parallel() - client := NewDeepSeekClient("ds-key", "https://api.deepseek.com/v1", "https://api.deepseek.com/anthropic", nil) - if client == nil { - t.Fatal("NewDeepSeekClient returned nil") - } - if client.Name() != "deepseek" { - t.Errorf("expected name 'deepseek', got %q", client.Name()) - } - if client.router.OpenAI == nil { + client := NewDeepSeekClient("ds-key", "https://api.deepseek.com/v1", &DeepSeekCompat) + if client == nil || client.openai == nil { t.Fatal("expected OpenAI client") } - if client.router.Anthropic == nil { - t.Fatal("expected Anthropic client for fallback") - } -} - -func TestNewDeepSeekClient_WithoutAnthropicFallback(t *testing.T) { - t.Parallel() - client := NewDeepSeekClient("ds-key", "https://api.deepseek.com/v1", "", nil) - if client.router.Anthropic != nil { - t.Fatal("expected no Anthropic client when anthropicBase is empty") - } -} - -func TestDeepSeekClient_Name(t *testing.T) { - t.Parallel() - client := NewDeepSeekClient("key", "https://api.deepseek.com/v1", "", nil) if client.Name() != "deepseek" { - t.Errorf("expected 'deepseek', got %q", client.Name()) - } -} - -func TestDeepSeekClient_ChatOpenAISuccess(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, map[string]any{ - "id": "chatcmpl-1", - "object": "chat.completion", - "choices": []map[string]any{{"message": map[string]string{"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}}, - "usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}, - }), nil - }) - - client := NewDeepSeekClient("ds-key", "https://api.deepseek.com/v1", "", nil) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - - resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "deepseek-chat", MaxTokens: 256}) - if err != nil { - t.Fatalf("Chat: %v", err) - } - if resp.Content != "Hello!" { - t.Errorf("content = %q, want Hello!", resp.Content) - } -} - -func TestDeepSeekClient_ChatFallbackToAnthropic(t *testing.T) { - t.Parallel() - var paths []string - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - paths = append(paths, req.URL.Path) - if strings.HasSuffix(req.URL.Path, "/chat/completions") { - return jsonResponse(http.StatusServiceUnavailable, map[string]any{ - "error": map[string]string{"message": "service unavailable"}, - }), nil - } - return jsonResponse(http.StatusOK, map[string]any{ - "id": "msg_1", "type": "message", "role": "assistant", - "content": []map[string]string{{"type": "text", "text": "Hello from Anthropic!"}}, - "stop_reason": "end_turn", - "usage": map[string]int{"input_tokens": 1, "output_tokens": 2}, - }), nil - }) - - client := NewDeepSeekClient("ds-key", "https://api.deepseek.com/v1", "https://api.deepseek.com/anthropic", nil) - // Disable retries to speed up fallback test - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - client.router.Anthropic.httpClient = &http.Client{Transport: transport} - - resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "deepseek-chat", MaxTokens: 256}) - if err != nil { - t.Fatalf("Chat: %v", err) - } - if resp.Content != "Hello from Anthropic!" { - t.Errorf("content = %q, want Hello from Anthropic!", resp.Content) - } - if len(paths) < 2 { - t.Fatalf("expected fallback to anthropic, got %v paths", len(paths)) - } -} - -func TestDeepSeekClient_StreamChatFallbackToAnthropic(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - if strings.HasSuffix(req.URL.Path, "/chat/completions") { - return jsonResponse(http.StatusServiceUnavailable, map[string]any{ - "error": map[string]string{"message": "service unavailable"}, - }), nil - } - if strings.HasSuffix(req.URL.Path, "/messages") { - body := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":10}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello stream!\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(body)), - }, nil - } - return jsonResponse(http.StatusOK, map[string]any{ - "id": "chatcmpl-1", "object": "chat.completion", - "choices": []map[string]any{{"message": map[string]string{"role": "assistant", "content": "OK"}, "finish_reason": "stop"}}, - }), nil - }) - - client := NewDeepSeekClient("ds-key", "https://api.deepseek.com/v1", "https://api.deepseek.com/anthropic", nil) - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - client.router.Anthropic.httpClient = &http.Client{Transport: transport} - - result, err := client.StreamChat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "deepseek-chat", MaxTokens: 256}) - if err != nil { - t.Fatalf("StreamChat: %v", err) - } - defer result.Close() - - var content string - for event := range result.Events { - if event.Type == "error" { - t.Fatalf("unexpected stream error: %s", event.Error) - } - if event.Type == "content" { - content += event.Content - } - } - if content != "Hello stream!" { - t.Errorf("content = %q, want Hello stream!", content) - } -} - -func TestDeepSeekClient_PingOpenAISuccess(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, map[string]string{"status": "ok"}), nil - }) - - client := NewDeepSeekClient("ds-key", "https://api.deepseek.com/v1", "", nil) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - - err := client.Ping(context.Background()) - if err != nil { - t.Fatalf("Ping: %v", err) + t.Fatalf("Name = %q", client.Name()) } } -func TestDeepSeekClient_PingFallbackToAnthropic(t *testing.T) { +func TestDeepSeekClient_ChatUsesOpenAIPath(t *testing.T) { t.Parallel() - var paths []string - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - paths = append(paths, req.URL.Path) - // Only fail the OpenAI Ping, not the Anthropic Ping - if strings.HasSuffix(req.URL.Path, "/models") && strings.Contains(req.URL.String(), "api.deepseek.com/v1") { - return nil, &transportError{msg: "connection refused"} - } - return jsonResponse(http.StatusOK, map[string]string{"status": "ok"}), nil - }) - - client := NewDeepSeekClient("ds-key", "https://api.deepseek.com/v1", "https://api.deepseek.com/anthropic", nil) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - client.router.Anthropic.httpClient = &http.Client{Transport: transport} - - err := client.Ping(context.Background()) + var path string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "chat", + "choices": []map[string]interface{}{ + {"message": map[string]string{"role": "assistant", "content": "ok"}, "finish_reason": "stop"}, + }, + }) + })) + defer server.Close() + + client := NewDeepSeekClient("key", server.URL, &DeepSeekCompat, core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "deepseek-v4-flash", MaxTokens: 16}) if err != nil { - t.Fatalf("Ping fallback: %v", err) + t.Fatal(err) } - if len(paths) < 2 { - t.Fatalf("expected anthropic fallback, got %v paths", len(paths)) + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) } -} - -func TestDeepSeekClient_StreamChatNoFallbackWhenAnthropicNil(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusServiceUnavailable, map[string]any{ - "error": map[string]string{"message": "service unavailable"}, - }), nil - }) - - client := NewDeepSeekClient("ds-key", "https://api.deepseek.com/v1", "", nil) - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - - _, err := client.StreamChat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "deepseek-chat", MaxTokens: 256}) - if err == nil { - t.Fatal("expected error when no fallback available") + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) } } -func TestDeepSeekClient_PingNoFallbackWhenAnthropicNil(t *testing.T) { +func TestDeepSeekClient_Ping(t *testing.T) { t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusUnauthorized, map[string]any{ - "error": map[string]string{"message": "unauthorized"}, - }), nil - }) - - client := NewDeepSeekClient("ds-key", "https://api.deepseek.com/v1", "", nil) - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - - err := client.Ping(context.Background()) - if err == nil { - t.Fatal("expected error when no fallback available") - } -} - -func TestDeepSeekClient_PingNonRetriableError(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusUnauthorized, map[string]any{ - "error": map[string]string{"message": "unauthorized"}, - }), nil - }) - - client := NewDeepSeekClient("ds-key", "https://api.deepseek.com/v1", "https://api.deepseek.com/anthropic", nil) - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - client.router.Anthropic.httpClient = &http.Client{Transport: transport} - - err := client.Ping(context.Background()) - if err == nil { - t.Fatal("expected error for non-retriable error") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewDeepSeekClient("key", server.URL, &DeepSeekCompat, core.WithTimeout(2*time.Second)) + if err := client.Ping(context.Background()); err != nil { + t.Fatal(err) } } diff --git a/client/adapters/mimo.go b/client/adapters/mimo.go index 93cda77b..67e8ba00 100644 --- a/client/adapters/mimo.go +++ b/client/adapters/mimo.go @@ -2,115 +2,70 @@ package adapters import ( "context" - "errors" - "log/slog" "net/http" "strconv" "strings" - "github.com/GrayCodeAI/eyrie/client/core" - "github.com/GrayCodeAI/eyrie/catalog/xiaomi" - "github.com/GrayCodeAI/eyrie/types" + "github.com/GrayCodeAI/eyrie/client/core" ) -// MiMoClient uses OpenAI-compatible MiMo endpoints first, with optional Anthropic-compat fallback. +// MiMoClient uses the official OpenAI-compatible Xiaomi MiMo surface only. type MiMoClient struct { - router ProtocolRouter + openai *OpenAIClient providerID string - logger *slog.Logger } -// NewMiMoClient builds a MiMo provider client (payg or token_plan gateway). -func NewMiMoClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, providerID string, opts ...core.ClientOption) *MiMoClient { +// NewMiMoClient builds an OpenAI-compatible MiMo client (payg or token_plan). +func NewMiMoClient(apiKey, openAIBase string, compat *OpenAICompatConfig, providerID string, opts ...core.ClientOption) *MiMoClient { openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") - anthropicBase = strings.TrimRight(strings.TrimSpace(anthropicBase), "/") mimoOpts := append(append([]core.ClientOption{}, opts...), core.WithMimoAuth(), core.WithProviderName(providerID)) - o := NewOpenAIClient(apiKey, openAIBase, compat, mimoOpts...) - var a *AnthropicClient - if anthropicBase != "" { - a = NewAnthropicClient(apiKey, anthropicBase, mimoOpts...) - } return &MiMoClient{ - router: ProtocolRouter{OpenAI: o, Anthropic: a}, + openai: NewOpenAIClient(apiKey, openAIBase, compat, mimoOpts...), providerID: providerID, - logger: slog.Default(), } } -// core.WithProviderName and core.WithMimoAuth live in client/core (options.go wraps them). - func (c *MiMoClient) Name() string { - if c.router.OpenAI != nil { - return c.router.OpenAI.Name() + if c.openai != nil { + return c.openai.Name() } return c.providerID } func (c *MiMoClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { - return c.router.Chat(ctx, messages, opts, ChatProtocolCompletions, func(err error, _ *core.EyrieResponse) bool { - if err != nil && c.router.Anthropic != nil && mimoFallbackChatError(err) { - c.logger.Info("MiMo: OpenAI endpoint failed; retrying via Anthropic compatibility", "provider", c.providerID, "error", err) - return true - } - return false - }) + return c.openai.Chat(ctx, messages, opts) } func (c *MiMoClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { - return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ - Primary: ChatProtocolCompletions, - FallbackOnError: func(err error) bool { - if c.router.Anthropic != nil && mimoFallbackChatError(err) { - c.logger.Info("MiMo: OpenAI stream failed; retrying via Anthropic compatibility", "provider", c.providerID, "error", err) - return true - } - return false - }, - }) + return c.openai.StreamChat(ctx, messages, opts) } func (c *MiMoClient) Ping(ctx context.Context) error { - if err := c.router.OpenAI.Ping(ctx); err == nil { - return nil - } else if c.router.Anthropic == nil || !mimoRetryableChatError(err) { - return err - } - return c.router.Anthropic.Ping(ctx) + return c.openai.Ping(ctx) } -func mimoFallbackChatError(err error) bool { - if mimoRetryableChatError(err) { - return true - } - if err == nil { - return false - } - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "param incorrect") || - strings.Contains(msg, "invalid format") || - strings.Contains(msg, "reasoning_content") || - (strings.Contains(msg, "http 400") && strings.Contains(msg, "xiaomi")) +var _ core.Provider = (*MiMoClient)(nil) + +// mimoAuthHeaders sets MiMo-preferred authentication on outbound requests. +func mimoAuthHeaders(req *http.Request, apiKey string) { + xiaomi.SetMimoRequestAuth(req, apiKey) } -func mimoRetryableChatError(err error) bool { +// ProviderID reports the configured MiMo gateway identity. +func (c *MiMoClient) ProviderID() string { return c.providerID } + +// MimoRetryableChatError reports whether an error is retryable for MiMo HTTP status rules. +// Kept for callers that classify MiMo errors outside the transport. +func MimoRetryableChatError(err error) bool { if err == nil { return false } - // MiMo-specific: check xiaomi helper first (401/403 are retryable for MiMo) msg := err.Error() if n := parseHTTPStatusFromError(msg); n > 0 { - if xiaomi.IsRetryableHTTPStatus(n) { - return true - } - } - // Structured path: trust core.EyrieError's IsRetriable - var eyrieErr *core.EyrieError - if errors.As(err, &eyrieErr) { - return eyrieErr.IsRetriable() + return xiaomi.IsRetryableHTTPStatus(n) } - // Conservative: only retry on explicitly transient errors (not the optimistic "unknown → true") - return types.IsTransient(err) + return false } func parseHTTPStatusFromError(msg string) int { @@ -130,19 +85,3 @@ func parseHTTPStatusFromError(msg string) int { } return 0 } - -var _ core.Provider = (*MiMoClient)(nil) - -// mimoAuthHeaders sets MiMo-preferred authentication on outbound requests. -func mimoAuthHeaders(req *http.Request, apiKey string) { - xiaomi.SetMimoRequestAuth(req, apiKey) -} - -// MimoRetryableChatError reports whether an error is retryable for MiMo. -func MimoRetryableChatError(err error) bool { return mimoRetryableChatError(err) } - -// MimoFallbackChatError reports whether the error should trigger Anthropic fallback. -func MimoFallbackChatError(err error) bool { return mimoFallbackChatError(err) } - -// ProviderID reports the configured MiMo gateway identity. -func (c *MiMoClient) ProviderID() string { return c.providerID } diff --git a/client/adapters/mimo_test.go b/client/adapters/mimo_test.go index 62d98309..91499081 100644 --- a/client/adapters/mimo_test.go +++ b/client/adapters/mimo_test.go @@ -3,7 +3,6 @@ package adapters import ( "context" "fmt" - "io" "net/http" "strings" "testing" @@ -12,36 +11,39 @@ import ( "github.com/GrayCodeAI/eyrie/types" ) -func TestMiMoClientChatFallsBackToAnthropicOnParamIncorrect(t *testing.T) { +func TestMiMoClient_OpenAIOnly(t *testing.T) { t.Parallel() - anthropicCalls := 0 - openAITransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.Path != "/v1/chat/completions" { - t.Fatalf("openai path = %q", req.URL.Path) - } - return jsonResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"message": "Param Incorrect"}}), nil - }) - anthropicTransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - anthropicCalls++ - if req.URL.Path != "/anthropic/v1/messages" { - t.Fatalf("anthropic path = %q", req.URL.Path) - } - if req.Header.Get("api-key") != "tp-test-key" { - t.Fatalf("missing MiMo api-key auth header") - } + client := NewMiMoClient("tp-test-key", "https://openai.example/v1/", &XiaomiCompat, "xiaomi_mimo_token_plan") + if client == nil || client.openai == nil { + t.Fatal("expected OpenAI client") + } + if got := client.openai.baseURL; got != "https://openai.example/v1" { + t.Fatalf("OpenAI base URL = %q", got) + } + if client.ProviderID() != "xiaomi_mimo_token_plan" { + t.Fatalf("ProviderID = %q", client.ProviderID()) + } +} + +func TestMiMoClient_ChatUsesOpenAIAndMimoAuth(t *testing.T) { + t.Parallel() + var gotPath, gotAPIKey, gotAuth string + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotPath = req.URL.Path + gotAPIKey = req.Header.Get("api-key") + gotAuth = req.Header.Get("Authorization") return jsonResponse(http.StatusOK, map[string]any{ - "id": "msg_1", - "type": "message", - "role": "assistant", - "content": []map[string]string{{"type": "text", "text": "ok"}}, - "stop_reason": "end_turn", - "usage": map[string]int{"input_tokens": 1, "output_tokens": 1}, + "id": "chat", + "choices": []map[string]any{ + {"message": map[string]string{"role": "assistant", "content": "ok"}, "finish_reason": "stop"}, + }, }), nil }) - client := NewMiMoClient("tp-test-key", "https://openai.example/v1", "https://anthropic.example/anthropic", &XiaomiCompat, "xiaomi_mimo_token_plan") - client.router.OpenAI.httpClient = &http.Client{Transport: openAITransport} - client.router.Anthropic.httpClient = &http.Client{Transport: anthropicTransport} + client := NewMiMoClient("tp-test-key", "https://openai.example/v1", &XiaomiCompat, "xiaomi_mimo_token_plan") + client.openai.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) + client.openai.httpClient = &http.Client{Transport: transport} + response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ Model: "mimo-v2.5-pro", MaxTokens: 1024, @@ -52,34 +54,17 @@ func TestMiMoClientChatFallsBackToAnthropicOnParamIncorrect(t *testing.T) { if response.Content != "ok" { t.Fatalf("content = %q, want ok", response.Content) } - if anthropicCalls != 1 { - t.Fatalf("anthropic calls = %d, want 1", anthropicCalls) - } -} - -func TestMiMoClientPreservesProtocolBaseURLs(t *testing.T) { - t.Parallel() - client := NewMiMoClient( - "key", - "https://openai.example/v1/", - "https://anthropic.example/anthropic/", - &XiaomiCompat, - "xiaomi_mimo_token_plan", - ) - if got := client.router.OpenAI.baseURL; got != "https://openai.example/v1" { - t.Fatalf("OpenAI base URL = %q", got) - } - if client.router.Anthropic == nil { - t.Fatal("Anthropic fallback client is nil") + if !strings.HasSuffix(gotPath, "/chat/completions") { + t.Fatalf("path = %q, want /chat/completions", gotPath) } - if got := client.router.Anthropic.baseURL; got != "https://anthropic.example/anthropic" { - t.Fatalf("Anthropic base URL = %q", got) + if gotAPIKey != "tp-test-key" && !strings.Contains(gotAuth, "tp-test-key") { + t.Fatalf("missing MiMo auth headers: api-key=%q Authorization=%q", gotAPIKey, gotAuth) } } func TestMiMoClient_Name(t *testing.T) { t.Parallel() - client := NewMiMoClient("key", "https://oai.example/v1", "https://ant.example", &XiaomiCompat, "providerA") + client := NewMiMoClient("key", "https://oai.example/v1", &XiaomiCompat, "providerA") if client.Name() != "providerA" { t.Errorf("Name() = %q, want providerA", client.Name()) } @@ -93,185 +78,63 @@ func TestMiMoClient_Name_NoOpenAI(t *testing.T) { } } -func TestMiMoClient_ProviderID(t *testing.T) { - t.Parallel() - client := NewMiMoClient("key", "https://oai.example/v1", "", &XiaomiCompat, "my_provider") - if client.ProviderID() != "my_provider" { - t.Errorf("ProviderID = %q", client.ProviderID()) - } -} - func TestMiMoClient_Ping_SuccessViaOpenAI(t *testing.T) { t.Parallel() - openAITransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { return jsonResponse(http.StatusOK, map[string]any{"data": []map[string]any{}}), nil }) - client := NewMiMoClient("key", "https://oai.example/v1", "https://ant.example", &XiaomiCompat, "p") - client.router.OpenAI.httpClient = &http.Client{Transport: openAITransport} - err := client.Ping(context.Background()) - if err != nil { - t.Fatalf("Ping: %v", err) - } -} - -func TestMiMoClient_Ping_FallbackToAnthropic(t *testing.T) { - t.Parallel() - anthropicPinged := false - openAITransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return nil, fmt.Errorf("connection refused") - }) - anthropicTransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - anthropicPinged = true - return jsonResponse(http.StatusOK, nil), nil - }) - client := NewMiMoClient("key", "https://oai.example/v1", "https://ant.example", &XiaomiCompat, "p") - client.router.OpenAI.httpClient = &http.Client{Transport: openAITransport} - client.router.Anthropic.httpClient = &http.Client{Transport: anthropicTransport} - err := client.Ping(context.Background()) - if err != nil { + client := NewMiMoClient("key", "https://oai.example/v1", &XiaomiCompat, "p") + client.openai.httpClient = &http.Client{Transport: transport} + if err := client.Ping(context.Background()); err != nil { t.Fatalf("Ping: %v", err) } - if !anthropicPinged { - t.Fatal("expected Anthropic ping fallback") - } -} - -func TestMiMoClient_Ping_NoFallbackOnNonRetryable(t *testing.T) { - t.Parallel() - openAITransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return nil, fmt.Errorf("some unknown error") - }) - client := NewMiMoClient("key", "https://oai.example/v1", "https://ant.example", &XiaomiCompat, "p") - client.router.OpenAI.httpClient = &http.Client{Transport: openAITransport} - err := client.Ping(context.Background()) - if err == nil { - t.Fatal("expected ping error when no fallback") - } -} - -func TestMiMoClient_Ping_NoAnthropicNoFallback(t *testing.T) { - t.Parallel() - openAITransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return nil, fmt.Errorf("some unknown error") - }) - client := NewMiMoClient("key", "https://oai.example/v1", "", &XiaomiCompat, "p") - client.router.OpenAI.httpClient = &http.Client{Transport: openAITransport} - err := client.Ping(context.Background()) - if err == nil { - t.Fatal("expected error without anthropic fallback client") - } } -func TestMiMoClient_Chat_NoFallbackOnNonRetriable(t *testing.T) { +func TestMiMoClient_Chat_SurfacesOpenAIError(t *testing.T) { t.Parallel() - openAITransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"message": "some other error"}}), nil + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"message": "Param Incorrect"}}), nil }) - client := NewMiMoClient("key", "https://oai.example/v1", "", &XiaomiCompat, "p") - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: openAITransport} + client := NewMiMoClient("key", "https://oai.example/v1", &XiaomiCompat, "p") + client.openai.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) + client.openai.httpClient = &http.Client{Transport: transport} _, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "mimo"}) if err == nil { - t.Fatal("expected error without fallback") + t.Fatal("expected OpenAI error without Anthropic fallback") } } -func TestMiMoClient_StreamChat_NoFallbackOnNonRetriable(t *testing.T) { +func TestMiMoClient_StreamChat_SurfacesOpenAIError(t *testing.T) { t.Parallel() - openAITransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"message": "some other error"}}), nil + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"message": "Param Incorrect"}}), nil }) - client := NewMiMoClient("key", "https://oai.example/v1", "", &XiaomiCompat, "p") - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: openAITransport} + client := NewMiMoClient("key", "https://oai.example/v1", &XiaomiCompat, "p") + client.openai.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) + client.openai.httpClient = &http.Client{Transport: transport} _, err := client.StreamChat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "mimo"}) if err == nil { - t.Fatal("expected error without fallback") - } -} - -func TestMiMoClient_StreamChat_FallbackToAnthropic(t *testing.T) { - t.Parallel() - openAITransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"message": "param incorrect"}}), nil - }) - anthropicTransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - body := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":1}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"stream ok\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(body)), - }, nil - }) - - client := NewMiMoClient("key", "https://oai.example/v1", "https://ant.example", &XiaomiCompat, "p") - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.Anthropic.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: openAITransport} - client.router.Anthropic.httpClient = &http.Client{Transport: anthropicTransport} - - result, err := client.StreamChat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "mimo-pro", MaxTokens: 64}) - if err != nil { - t.Fatalf("StreamChat: %v", err) - } - defer result.Close() - var content string - for evt := range result.Events { - if evt.Type == "content" { - content += evt.Content - } - } - if content != "stream ok" { - t.Errorf("content = %q, want stream ok", content) - } -} - -func TestMimoFallbackChatError(t *testing.T) { - tests := []struct { - err error - want bool - }{ - {nil, false}, - {fmt.Errorf("param incorrect"), true}, - {fmt.Errorf("invalid format"), true}, - {fmt.Errorf("reasoning_content"), true}, - {fmt.Errorf("HTTP 400 xiaomi"), true}, - {fmt.Errorf("HTTP 400"), false}, - {fmt.Errorf("something else"), false}, - {fmt.Errorf(""), false}, - } - for _, tt := range tests { - got := MimoFallbackChatError(tt.err) - if got != tt.want { - t.Errorf("MimoFallbackChatError(%v) = %v, want %v", tt.err, got, tt.want) - } + t.Fatal("expected OpenAI error without Anthropic fallback") } } func TestMimoRetryableChatError_HTTPStatus(t *testing.T) { - err := fmt.Errorf("HTTP 401") - if !MimoRetryableChatError(err) { + t.Parallel() + if !MimoRetryableChatError(fmt.Errorf("HTTP 401")) { t.Error("expected 401 to be retryable") } -} - -func TestMimoRetryableChatError_TransientMessage(t *testing.T) { - err := fmt.Errorf("connection refused") - if !MimoRetryableChatError(err) { - t.Error("expected connection refused to be retryable") + if MimoRetryableChatError(fmt.Errorf("HTTP 400")) { + t.Error("expected 400 not retryable") } -} - -func TestMimoRetryableChatError_NonTransient(t *testing.T) { - err := fmt.Errorf("some unknown error") - if MimoRetryableChatError(err) { - t.Error("expected unknown error to NOT be retryable") + if MimoRetryableChatError(fmt.Errorf("connection refused")) { + t.Error("expected non-HTTP errors not retryable via status helper") } } func TestParseHTTPStatusFromError(t *testing.T) { + t.Parallel() tests := []struct { msg string want int diff --git a/client/adapters/openai.go b/client/adapters/openai.go index b3159410..7885fc09 100644 --- a/client/adapters/openai.go +++ b/client/adapters/openai.go @@ -121,21 +121,26 @@ type openaiRequest struct { ResponseFormat interface{} `json:"response_format,omitempty"` ReasoningEffort string `json:"reasoning_effort,omitempty"` Thinking map[string]interface{} `json:"thinking,omitempty"` - Stop interface{} `json:"stop,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` - User string `json:"user,omitempty"` - PresencePenalty *float64 `json:"presence_penalty,omitempty"` - FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` - N *int `json:"n,omitempty"` - LogProbs *bool `json:"logprobs,omitempty"` - TopLogProbs *int `json:"top_logprobs,omitempty"` - Seed *int `json:"seed,omitempty"` - Store *bool `json:"store,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Modalities []string `json:"modalities,omitempty"` - Audio map[string]interface{} `json:"audio,omitempty"` - Prediction map[string]interface{} `json:"prediction,omitempty"` - WebSearchOptions map[string]interface{} `json:"web_search_options,omitempty"` + // Reasoning is OpenRouter's unified reasoning object (enabled / effort / max_tokens). + // Docs: https://openrouter.ai/docs/guides/best-practices/reasoning-tokens + Reasoning map[string]interface{} `json:"reasoning,omitempty"` + ChatTemplateKwargs map[string]interface{} `json:"chat_template_kwargs,omitempty"` + EnableThinking *bool `json:"enable_thinking,omitempty"` // Qwen/DashScope-style top-level flag + Stop interface{} `json:"stop,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + User string `json:"user,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + N *int `json:"n,omitempty"` + LogProbs *bool `json:"logprobs,omitempty"` + TopLogProbs *int `json:"top_logprobs,omitempty"` + Seed *int `json:"seed,omitempty"` + Store *bool `json:"store,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Modalities []string `json:"modalities,omitempty"` + Audio map[string]interface{} `json:"audio,omitempty"` + Prediction map[string]interface{} `json:"prediction,omitempty"` + WebSearchOptions map[string]interface{} `json:"web_search_options,omitempty"` } type streamOptions struct { @@ -314,14 +319,20 @@ func buildRequestBase(messages []core.EyrieMessage, opts core.ChatOptions, strea } req.Tools = tools } - maxTok := opts.MaxTokens - if maxTok == 0 { - maxTok = 4096 - } - if compat != nil && compat.MaxTokensField == "max_completion_tokens" { - req.MaxCompletionTokens = &maxTok - } else { - req.MaxTokens = &maxTok + // When OmitMaxTokens is set, leave max_tokens unset so the provider applies + // its own default. Sending the 4096 default can trigger an + // insufficient_user_quota hold for providers that pre-authorize the maximum + // token cost (e.g. Agnes AI). + if compat == nil || !compat.OmitMaxTokens { + maxTok := opts.MaxTokens + if maxTok == 0 { + maxTok = 4096 + } + if compat != nil && compat.MaxTokensField == "max_completion_tokens" { + req.MaxCompletionTokens = &maxTok + } else { + req.MaxTokens = &maxTok + } } if stream { if compat == nil || compat.SupportsUsageInStreaming { @@ -402,12 +413,52 @@ func buildRequestBase(messages []core.EyrieMessage, opts core.ChatOptions, strea if len(opts.Metadata) > 0 { req.Metadata = opts.Metadata } - if compat != nil && compat.ThinkingFormat == "zai" && opts.GLMThinkingEnabled != nil { - thinkingType := "disabled" - if *opts.GLMThinkingEnabled { - thinkingType = "enabled" + if compat != nil { + thinkingEnabled := opts.ThinkingEnabled + if thinkingEnabled == nil { + thinkingEnabled = opts.GLMThinkingEnabled // deprecated Z.AI-era alias + } + if thinkingEnabled == nil && compat.DefaultDisableThinking { + disabled := false + thinkingEnabled = &disabled + } + if thinkingEnabled != nil { + switch compat.ThinkingFormat { + case "zai", "longcat", "kimi", "deepseek", "xiaomi": + // Official thinking={"type":"enabled"|"disabled"} shape used by + // Z.AI, LongCat, Kimi (k2.5/k2.6), DeepSeek V4, and Xiaomi MiMo. + thinkingType := "disabled" + if *thinkingEnabled { + thinkingType = "enabled" + } + req.Thinking = map[string]interface{}{"type": thinkingType} + case "minimax": + // MiniMax OpenAI docs: thinking.type is "disabled" | "adaptive" + // (not "enabled"). Default when omitted is thinking on. + thinkingType := "disabled" + if *thinkingEnabled { + thinkingType = "adaptive" + } + req.Thinking = map[string]interface{}{"type": thinkingType} + case "agnes": + // Agnes OpenAI docs: chat_template_kwargs.enable_thinking + req.ChatTemplateKwargs = map[string]interface{}{"enable_thinking": *thinkingEnabled} + case "qwen": + // Qwen/DashScope OpenAI-compatible: top-level enable_thinking + // (https://docs.qwencloud.com/developer-guides/text-generation/thinking). + v := *thinkingEnabled + req.EnableThinking = &v + case "openrouter": + // OpenRouter unified reasoning object + // (https://openrouter.ai/docs/guides/best-practices/reasoning-tokens). + if *thinkingEnabled { + req.Reasoning = map[string]interface{}{"enabled": true} + } else { + // effort "none" is the documented OpenAI-style disable. + req.Reasoning = map[string]interface{}{"effort": "none"} + } + } } - req.Thinking = map[string]interface{}{"type": thinkingType} } return req } diff --git a/client/adapters/openai_test.go b/client/adapters/openai_test.go index 20ac817d..81b4364e 100644 --- a/client/adapters/openai_test.go +++ b/client/adapters/openai_test.go @@ -214,6 +214,21 @@ func TestBuildRequestBase_MaxCompletionTokens(t *testing.T) { } } +func TestBuildRequestBase_OmitMaxTokens(t *testing.T) { + t.Parallel() + // Agnes AI pre-authorizes the maximum token cost; sending the default 4096 + // can exceed the account balance and trigger insufficient_user_quota. With + // OmitMaxTokens set, neither max_tokens nor max_completion_tokens is sent. + compat := &OpenAICompatConfig{MaxTokensField: "max_tokens", OmitMaxTokens: true} + req := BuildRequestBase([]core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "agnes-2.5-pro-alpha", MaxTokens: 0}, false, compat) + if req.MaxTokens != nil { + t.Errorf("MaxTokens should be nil when OmitMaxTokens is set, got %v", *req.MaxTokens) + } + if req.MaxCompletionTokens != nil { + t.Errorf("MaxCompletionTokens should be nil when OmitMaxTokens is set, got %v", *req.MaxCompletionTokens) + } +} + func TestBuildRequestBase_StreamOptions(t *testing.T) { t.Parallel() req := BuildRequestBase([]core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "gpt-4o", MaxTokens: 256}, true, nil) @@ -432,6 +447,103 @@ func TestBuildRequestBase_ZAIThinking(t *testing.T) { } } +func TestBuildRequestBase_LongCatDefaultsThinkingDisabled(t *testing.T) { + t.Parallel() + req := BuildRequestBase([]core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "LongCat-2.0", MaxTokens: 256, + }, false, &LongCatCompat) + if req.Thinking == nil || req.Thinking["type"] != "disabled" { + t.Fatalf("LongCat default Thinking = %v, want type=disabled", req.Thinking) + } + enabled := true + reqOn := BuildRequestBase([]core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "LongCat-2.0", MaxTokens: 256, + GLMThinkingEnabled: &enabled, + }, false, &LongCatCompat) + if reqOn.Thinking == nil || reqOn.Thinking["type"] != "enabled" { + t.Fatalf("LongCat opt-in Thinking = %v, want type=enabled", reqOn.Thinking) + } +} + +func TestBuildRequestBase_AgnesThinking(t *testing.T) { + t.Parallel() + enabled := true + compat := &OpenAICompatConfig{ThinkingFormat: "agnes", OmitMaxTokens: true} + req := BuildRequestBase([]core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "agnes-2.0-flash", MaxTokens: 0, + GLMThinkingEnabled: &enabled, + }, false, compat) + if req.ChatTemplateKwargs == nil || req.ChatTemplateKwargs["enable_thinking"] != true { + t.Errorf("ChatTemplateKwargs = %v", req.ChatTemplateKwargs) + } + if req.Thinking != nil { + t.Errorf("Agnes OpenAI path should not set thinking object, got %v", req.Thinking) + } +} + +func TestBuildRequestBase_OpenRouterReasoning(t *testing.T) { + t.Parallel() + enabled := true + disabled := false + reqOn := BuildRequestBase([]core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "openrouter/auto", MaxTokens: 256, ThinkingEnabled: &enabled, + }, false, &OpenRouterCompat) + if reqOn.Reasoning == nil || reqOn.Reasoning["enabled"] != true { + t.Fatalf("OpenRouter on Reasoning = %v", reqOn.Reasoning) + } + reqOff := BuildRequestBase([]core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "openrouter/auto", MaxTokens: 256, ThinkingEnabled: &disabled, + }, false, &OpenRouterCompat) + if reqOff.Reasoning == nil || reqOff.Reasoning["effort"] != "none" { + t.Fatalf("OpenRouter off Reasoning = %v, want effort=none", reqOff.Reasoning) + } +} + +func TestBuildRequestBase_KimiDeepSeekXiaomiThinking(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + compat *OpenAICompatConfig + }{ + {"kimi", &KimiCompat}, + {"deepseek", &DeepSeekCompat}, + {"xiaomi", &XiaomiCompat}, + } { + t.Run(tc.name, func(t *testing.T) { + req := BuildRequestBase([]core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "m", MaxTokens: 256, + }, false, tc.compat) + if req.Thinking == nil || req.Thinking["type"] != "disabled" { + t.Fatalf("default Thinking = %v, want type=disabled", req.Thinking) + } + enabled := true + reqOn := BuildRequestBase([]core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "m", MaxTokens: 256, ThinkingEnabled: &enabled, + }, false, tc.compat) + if reqOn.Thinking == nil || reqOn.Thinking["type"] != "enabled" { + t.Fatalf("opt-in Thinking = %v, want type=enabled", reqOn.Thinking) + } + }) + } +} + +func TestBuildRequestBase_MiniMaxThinkingAdaptive(t *testing.T) { + t.Parallel() + req := BuildRequestBase([]core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "MiniMax-M3", MaxTokens: 256, + }, false, &MiniMaxCompat) + if req.Thinking == nil || req.Thinking["type"] != "disabled" { + t.Fatalf("default Thinking = %v, want type=disabled", req.Thinking) + } + enabled := true + reqOn := BuildRequestBase([]core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ + Model: "MiniMax-M3", MaxTokens: 256, ThinkingEnabled: &enabled, + }, false, &MiniMaxCompat) + if reqOn.Thinking == nil || reqOn.Thinking["type"] != "adaptive" { + t.Fatalf("opt-in Thinking = %v, want type=adaptive", reqOn.Thinking) + } +} + func TestOpenAIClient_Ping_MimoAuthRetry(t *testing.T) { t.Parallel() calls := 0 diff --git a/client/adapters/opencodego.go b/client/adapters/opencodego.go index 33fdb9ac..54f3ad1d 100644 --- a/client/adapters/opencodego.go +++ b/client/adapters/opencodego.go @@ -4,16 +4,19 @@ import ( "context" "strings" - "github.com/GrayCodeAI/eyrie/client/core" - "github.com/GrayCodeAI/eyrie/catalog/opencodego" + "github.com/GrayCodeAI/eyrie/client/core" ) -// OpenCodeGoClient routes OpenCode Go models through OpenAIClient and -// AnthropicClient per opencode.ai/docs/go, with cross-protocol fallback when -// the primary path returns no answer text (e.g. reasoning-only MiniMax streams). +// OpenCodeGoClient routes each model to exactly one protocol: +// - OpenAI /v1/chat/completions when the model has an OpenAI-compatible endpoint +// - Anthropic /v1/messages when the model is Anthropic-only on OpenCode Go +// +// Never falls back across protocols for the same request (official Go docs list +// one endpoint per model). type OpenCodeGoClient struct { - router ProtocolRouter + openai *OpenAIClient + anthropic *AnthropicClient } // NewOpenCodeGoClient builds an OpenCode Go provider client. @@ -23,10 +26,10 @@ func NewOpenCodeGoClient(apiKey, baseURL string, opts ...core.ClientOption) *Ope openBase = opencodego.DefaultBaseURL } ocgOpts := append(append([]core.ClientOption{}, opts...), core.WithProviderName("opencodego")) - return &OpenCodeGoClient{router: ProtocolRouter{ - OpenAI: NewOpenAIClient(apiKey, openBase, &OpenCodeGoCompat, ocgOpts...), - Anthropic: NewAnthropicClient(apiKey, AnthropicBaseFromOpenAIV1(openBase), ocgOpts...), - }} + return &OpenCodeGoClient{ + openai: NewOpenAIClient(apiKey, openBase, &OpenCodeGoCompat, ocgOpts...), + anthropic: NewAnthropicClient(apiKey, AnthropicBaseFromOpenAIV1(openBase), ocgOpts...), + } } func (c *OpenCodeGoClient) Name() string { return "opencodego" } @@ -34,48 +37,22 @@ func (c *OpenCodeGoClient) Name() string { return "opencodego" } func (c *OpenCodeGoClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { opts.Model = opencodego.NativeModelID(opts.Model) if opencodego.UsesMessagesAPI(opts.Model) { - return c.router.Chat(ctx, messages, opts, ChatProtocolMessages, openCodeGoMessagesFallback) + return c.anthropic.Chat(ctx, messages, opts) } - return c.router.OpenAI.Chat(ctx, messages, opts) + return c.openai.Chat(ctx, messages, opts) } func (c *OpenCodeGoClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { opts.Model = opencodego.NativeModelID(opts.Model) if opencodego.UsesMessagesAPI(opts.Model) { - return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ - Primary: ChatProtocolMessages, - ReasoningOnlyFallback: true, - }) + return c.anthropic.StreamChat(ctx, messages, opts) } - return c.router.OpenAI.StreamChat(ctx, messages, opts) + return c.openai.StreamChat(ctx, messages, opts) } func (c *OpenCodeGoClient) Ping(ctx context.Context) error { - if err := c.router.OpenAI.Ping(ctx); err == nil { - return nil - } - return c.router.Anthropic.Ping(ctx) -} - -func openCodeGoMessagesFallback(primaryErr error, primaryResp *core.EyrieResponse) bool { - if primaryErr != nil { - return oaCompatUnsupportedError(primaryErr) - } - return !core.ResponseHasContent(primaryResp) -} - -func oaCompatUnsupportedError(err error) bool { - if err == nil { - return false - } - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "status=401") || - strings.Contains(msg, "http 401") || - strings.Contains(msg, "oa-compat") || - strings.Contains(msg, "not supported") + // Health-check the OpenAI gateway surface; Anthropic-only models share the same host. + return c.openai.Ping(ctx) } -// OACompatUnsupportedError reports whether an error indicates OA-compat is unsupported. -func OACompatUnsupportedError(err error) bool { return oaCompatUnsupportedError(err) } - var _ core.Provider = (*OpenCodeGoClient)(nil) diff --git a/client/adapters/opencodego_test.go b/client/adapters/opencodego_test.go index 11cbe8ac..940464f3 100644 --- a/client/adapters/opencodego_test.go +++ b/client/adapters/opencodego_test.go @@ -2,8 +2,6 @@ package adapters import ( "context" - "fmt" - "io" "net/http" "strings" "testing" @@ -27,7 +25,7 @@ func TestOpenCodeGoClientRoutesMiniMaxToAnthropic(t *testing.T) { }) client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - client.router.Anthropic.httpClient = &http.Client{Transport: transport} + client.anthropic.httpClient = &http.Client{Transport: transport} response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ Model: "minimax-m2.5", MaxTokens: 256, }) @@ -60,7 +58,7 @@ func TestOpenCodeGoClientRoutesKimiToOpenAI(t *testing.T) { }) client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - client.router.OpenAI.httpClient = &http.Client{Transport: transport} + client.openai.httpClient = &http.Client{Transport: transport} response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ Model: "kimi-k2.5", MaxTokens: 256, }) @@ -75,7 +73,7 @@ func TestOpenCodeGoClientRoutesKimiToOpenAI(t *testing.T) { } } -func TestOpenCodeGoClientQwen401FallsBackToOpenAI(t *testing.T) { +func TestOpenCodeGoClientQwenUsesAnthropicOnly(t *testing.T) { t.Parallel() var paths []string transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { @@ -85,62 +83,22 @@ func TestOpenCodeGoClientQwen401FallsBackToOpenAI(t *testing.T) { "error": map[string]string{"message": "Invalid API key"}, }), nil } - return jsonResponse(http.StatusOK, map[string]any{ - "id": "chatcmpl-1", "object": "chat.completion", - "choices": []map[string]any{ - {"message": map[string]string{"role": "assistant", "content": "OK"}, "finish_reason": "stop"}, - }, - }), nil + t.Fatalf("unexpected OpenAI path %q — no cross-protocol fallback", req.URL.Path) + return nil, nil }) client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - client.router.Anthropic.httpClient = &http.Client{Transport: transport} - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ + client.anthropic.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) + client.anthropic.httpClient = &http.Client{Transport: transport} + client.openai.httpClient = &http.Client{Transport: transport} + _, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ Model: "qwen3.7-max", MaxTokens: 256, }) - if err != nil { - t.Fatalf("Chat: %v", err) + if err == nil { + t.Fatal("expected Anthropic error without OpenAI fallback") } - if response.Content != "OK" { - t.Fatalf("content = %q, want OK; paths=%v", response.Content, paths) - } -} - -func TestOpenCodeGoClientMessagesEmptyFallsBackToOpenAI(t *testing.T) { - t.Parallel() - var paths []string - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - paths = append(paths, req.URL.Path) - if strings.HasSuffix(req.URL.Path, "/messages") { - return jsonResponse(http.StatusOK, map[string]any{ - "id": "msg_1", "type": "message", "role": "assistant", - "content": []map[string]string{{"type": "thinking", "thinking": "hmm"}}, - "stop_reason": "end_turn", - }), nil - } - return jsonResponse(http.StatusOK, map[string]any{ - "id": "chatcmpl-1", "object": "chat.completion", - "choices": []map[string]any{ - {"message": map[string]string{"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}, - }, - }), nil - }) - - client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - client.router.Anthropic.httpClient = &http.Client{Transport: transport} - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - response, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ - Model: "minimax-m3", MaxTokens: 256, - }) - if err != nil { - t.Fatalf("Chat: %v", err) - } - if response.Content != "Hello!" { - t.Fatalf("content = %q, want Hello!; paths=%v", response.Content, paths) - } - if len(paths) < 2 { - t.Fatalf("expected anthropic then openai, got %v", paths) + if len(paths) != 1 || !strings.HasSuffix(paths[0], "/messages") { + t.Fatalf("paths = %v, want single /messages call", paths) } } @@ -164,7 +122,7 @@ func TestOpenCodeGoClientNormalizesModelID(t *testing.T) { }), nil }) client := NewOpenCodeGoClient("key", "https://opencode.example/zen/go/v1") - client.router.OpenAI.httpClient = &http.Client{Transport: transport} + client.openai.httpClient = &http.Client{Transport: transport} _, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{ Model: "opencode-go/kimi-k2.6", MaxTokens: 16, }) @@ -176,65 +134,6 @@ func TestOpenCodeGoClientNormalizesModelID(t *testing.T) { } } -func TestOpenCodeGoClientStreamMiniMaxReasoningOnlyFallsBackToChat(t *testing.T) { - t.Parallel() - var paths []string - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - paths = append(paths, req.URL.Path) - if strings.HasSuffix(req.URL.Path, "/messages") { - body := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":10}}}\n\n" + - "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\"}}\n\n" + - "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"text\":\"hmm\"}}\n\n" + - "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + - "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n" + - "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(body)), - }, nil - } - if strings.HasSuffix(req.URL.Path, "/chat/completions") && req.Header.Get("Accept") == "text/event-stream" { - t.Fatal("stream fallback should not run before non-streaming chat fallback") - } - return jsonResponse(http.StatusOK, map[string]any{ - "id": "chatcmpl-1", "object": "chat.completion", - "choices": []map[string]any{ - {"message": map[string]string{"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}, - }, - }), nil - }) - - client := NewOpenCodeGoClient("ocg-test-key", "https://opencode.example/zen/go/v1") - client.router.Anthropic.httpClient = &http.Client{Transport: transport} - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - result, err := client.StreamChat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hello how are you?"}}, core.ChatOptions{ - Model: "minimax-m3", MaxTokens: 256, - }) - if err != nil { - t.Fatalf("StreamChat: %v", err) - } - defer result.Close() - - var content string - for event := range result.Events { - switch event.Type { - case "thinking": - t.Fatal("reasoning-only primary stream must not leak thinking before chat fallback") - case "content": - content += event.Content - case "error": - t.Fatalf("unexpected stream error: %s", event.Error) - } - } - if content != "Hello!" { - t.Fatalf("content = %q, want Hello!; paths=%v", content, paths) - } - if len(paths) < 2 { - t.Fatalf("expected /messages then /chat/completions, got %v", paths) - } -} - func TestOpenCodeGoClient_Name(t *testing.T) { t.Parallel() client := NewOpenCodeGoClient("key", "https://opencode.example/zen/go/v1") @@ -249,54 +148,22 @@ func TestOpenCodeGoClient_Ping_Success(t *testing.T) { return jsonResponse(http.StatusOK, map[string]any{}), nil }) client := NewOpenCodeGoClient("key", "https://opencode.example/zen/go/v1") - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - client.router.Anthropic.httpClient = &http.Client{Transport: transport} + client.openai.httpClient = &http.Client{Transport: transport} if err := client.Ping(context.Background()); err != nil { t.Fatalf("Ping: %v", err) } } -func TestOpenCodeGoClient_Ping_FallbackToAnthropic(t *testing.T) { +func TestOpenCodeGoClient_Ping_OpenAIOnly(t *testing.T) { t.Parallel() - callCount := 0 transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - callCount++ - if callCount == 1 { - return jsonResponse(http.StatusUnauthorized, map[string]any{}), nil - } - return jsonResponse(http.StatusOK, map[string]any{}), nil + return jsonResponse(http.StatusUnauthorized, map[string]any{}), nil }) client := NewOpenCodeGoClient("key", "https://openai.example/v1") - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.Anthropic.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - client.router.Anthropic.httpClient = &http.Client{Transport: transport} - if err := client.Ping(context.Background()); err != nil { - t.Fatalf("Ping: %v", err) - } -} - -func TestOACompatUnsupportedError(t *testing.T) { - t.Parallel() - tests := []struct { - name string - err error - want bool - }{ - {"nil error", nil, false}, - {"401 status", fmt.Errorf("status=401"), true}, - {"http 401", fmt.Errorf("http 401"), true}, - {"oa-compat", fmt.Errorf("oa-compat unsupported"), true}, - {"not supported", fmt.Errorf("not supported"), true}, - {"other error", fmt.Errorf("something else"), false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := OACompatUnsupportedError(tt.err) - if got != tt.want { - t.Errorf("OACompatUnsupportedError(%v) = %v, want %v", tt.err, got, tt.want) - } - }) + client.openai.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) + client.openai.httpClient = &http.Client{Transport: transport} + if err := client.Ping(context.Background()); err == nil { + t.Fatal("expected ping error without Anthropic fallback") } } diff --git a/client/adapters/protocol_router.go b/client/adapters/protocol_router.go deleted file mode 100644 index 86cda443..00000000 --- a/client/adapters/protocol_router.go +++ /dev/null @@ -1,241 +0,0 @@ -package adapters - -import ( - "context" - "fmt" - "strings" - - "github.com/GrayCodeAI/eyrie/client/core" - "github.com/GrayCodeAI/hawk-core-contracts/llm" -) - -// ChatProtocol selects which existing eyrie client handles a gateway request. -type ChatProtocol int - -const ( - // ChatProtocolCompletions routes via OpenAIClient (POST /v1/chat/completions). - ChatProtocolCompletions ChatProtocol = iota - // ChatProtocolMessages routes via AnthropicClient (POST /v1/messages). - ChatProtocolMessages -) - -type streamOpener func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, error) - -type chatOpener func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.EyrieResponse, error) - -// protocolStreamFallback retries a reasoning-only /v1/messages stream via the -// alternate protocol. Non-streaming Chat is tried first because OpenCode Go -// MiniMax often returns answer text on chat/completions while the stream only -// exposes reasoning_content. -type protocolStreamFallback struct { - stream streamOpener - chat chatOpener -} - -// ProtocolChatFallback decides whether to retry via the alternate protocol -// after the primary attempt. resp is non-nil only when primary returned err=nil. -type ProtocolChatFallback func(primaryErr error, primaryResp *core.EyrieResponse) bool - -// ProtocolRouter picks between OpenAIClient and AnthropicClient for gateways -// that expose both APIs (OpenCode Go, MiMo). All HTTP work stays in openai.go -// and anthropic.go; this type only orchestrates routing and fallback. -type ProtocolRouter struct { - OpenAI *OpenAIClient - Anthropic *AnthropicClient -} - -// ProtocolStreamConfig controls streaming across two protocols. -type ProtocolStreamConfig struct { - Primary ChatProtocol - FallbackOnError func(error) bool - ReasoningOnlyFallback bool // retry alternate protocol when primary stream is reasoning-only -} - -// Chat sends a request via the primary protocol, optionally falling back to the -// alternate protocol when fallback returns true. -func (r ProtocolRouter) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions, primary ChatProtocol, fallback ProtocolChatFallback) (*core.EyrieResponse, error) { - primaryClient, fallbackClient := r.providers(primary) - resp, err := primaryClient.Chat(ctx, messages, opts) - if fallback == nil || !fallback(err, resp) { - return resp, err - } - fallbackResp, fallbackErr := fallbackClient.Chat(ctx, messages, opts) - if fallbackErr == nil && core.ResponseHasContent(fallbackResp) { - return fallbackResp, nil - } - return resp, err -} - -// StreamChat streams via the primary protocol. When FallbackOnError matches, -// the alternate protocol is tried immediately. When ReasoningOnlyFallback is set -// and the primary is /v1/messages, an empty reasoning-only stream retries via -// chat/completions. -func (r ProtocolRouter) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions, cfg ProtocolStreamConfig) (*core.StreamResult, error) { - primaryClient, fallbackClient := r.providers(cfg.Primary) - result, err := primaryClient.StreamChat(ctx, messages, opts) - if err != nil { - if cfg.FallbackOnError != nil && cfg.FallbackOnError(err) { - return fallbackClient.StreamChat(ctx, messages, opts) - } - return result, err - } - if cfg.ReasoningOnlyFallback && cfg.Primary == ChatProtocolMessages { - fallback := fallbackClient - return newStreamWithReasoningFallback(ctx, messages, opts, result, protocolStreamFallback{ - chat: func(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { - return fallback.Chat(ctx, messages, opts) - }, - stream: func(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { - return fallback.StreamChat(ctx, messages, opts) - }, - }), nil - } - return result, nil -} - -func (r ProtocolRouter) providers(primary ChatProtocol) (core.Provider, core.Provider) { - if primary == ChatProtocolMessages { - return r.Anthropic, r.OpenAI - } - return r.OpenAI, r.Anthropic -} - -// AnthropicBaseFromOpenAIV1 strips a trailing /v1 from an OpenAI-compatible base URL. -func AnthropicBaseFromOpenAIV1(openAIBase string) string { - base := strings.TrimRight(strings.TrimSpace(openAIBase), "/") - if strings.HasSuffix(base, "/v1") { - return strings.TrimSuffix(base, "/v1") - } - return base -} - -func streamResultFromChat(resp *core.EyrieResponse) *core.StreamResult { - out := make(chan core.EyrieStreamEvent, core.StreamChannelBuffer) - go func() { - defer close(out) - if resp == nil { - return - } - if strings.TrimSpace(resp.Thinking) != "" { - out <- core.EyrieStreamEvent{Type: "thinking", Thinking: resp.Thinking} - } - if strings.TrimSpace(resp.Content) != "" { - out <- core.EyrieStreamEvent{Type: "content", Content: resp.Content} - } - for i := range resp.ToolCalls { - tc := resp.ToolCalls[i] - out <- core.EyrieStreamEvent{Type: "tool_call", ToolCall: &tc} - } - if resp.Usage != nil { - out <- core.EyrieStreamEvent{Type: "usage", Usage: resp.Usage} - } - stop := resp.FinishReason - if stop == "" { - stop = "stop" - } - out <- core.EyrieStreamEvent{Type: "done", StopReason: stop} - }() - return llm.NewStreamResult(out, "", func() {}) -} - -func (f protocolStreamFallback) open(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { - if f.chat != nil { - resp, err := f.chat(ctx, messages, opts) - if err == nil && core.ResponseHasContent(resp) { - return streamResultFromChat(resp), nil - } - } - if f.stream != nil { - return f.stream(ctx, messages, opts) - } - return nil, fmt.Errorf("eyrie: protocol stream fallback is not configured") -} - -func newStreamWithReasoningFallback(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions, primary *core.StreamResult, fallback protocolStreamFallback) *core.StreamResult { - out := make(chan core.EyrieStreamEvent, core.StreamChannelBuffer) - cancelCtx, cancel := context.WithCancel(ctx) - go func() { - defer close(out) - defer primary.Close() - defer cancel() - - var ( - sawReasoning bool - content strings.Builder - toolCalls int - buffered []core.EyrieStreamEvent - streamErr bool - ) - flush := func() { - for _, ev := range buffered { - select { - case out <- ev: - case <-cancelCtx.Done(): - return - } - } - buffered = nil - } - for ev := range primary.Events { - switch ev.Type { - case "thinking": - sawReasoning = true - case "content": - content.WriteString(ev.Content) - case "tool_call": - toolCalls++ - case "error": - if !isReasoningOnlyStreamDiagnostic(ev.Error) { - streamErr = true - } - } - if strings.TrimSpace(content.String()) != "" || toolCalls > 0 { - flush() - select { - case out <- ev: - case <-cancelCtx.Done(): - return - } - continue - } - buffered = append(buffered, ev) - } - - health := core.DetectResponseHealth(core.ResponseSignals{ - SawReasoning: sawReasoning, - ContentLen: len(strings.TrimSpace(content.String())), - ToolCalls: toolCalls, - StreamEnded: true, - StreamErr: streamErr, - }) - if health != core.ResponseErrorOnlyReasoning { - flush() - return - } - - fallbackResult, err := fallback.open(cancelCtx, messages, opts) - if err != nil { - flush() - select { - case out <- core.EyrieStreamEvent{Type: "error", Error: err.Error()}: - case <-cancelCtx.Done(): - } - return - } - defer fallbackResult.Close() - for ev := range fallbackResult.Events { - select { - case out <- ev: - case <-cancelCtx.Done(): - return - } - } - }() - return llm.NewStreamResult(out, "", cancel) -} - -func isReasoningOnlyStreamDiagnostic(message string) bool { - message = strings.ToLower(strings.TrimSpace(message)) - return strings.Contains(message, "error_only_reasoning") || - strings.Contains(message, "reasoning tokens but no answer") -} diff --git a/client/adapters/protocol_router_test.go b/client/adapters/protocol_router_test.go deleted file mode 100644 index 6897d2dc..00000000 --- a/client/adapters/protocol_router_test.go +++ /dev/null @@ -1,425 +0,0 @@ -package adapters - -import ( - "context" - "fmt" - "net/http" - "testing" - - "github.com/GrayCodeAI/eyrie/client/core" - "github.com/GrayCodeAI/hawk-core-contracts/llm" -) - -func TestStreamResultFromChat(t *testing.T) { - t.Parallel() - result := streamResultFromChat(&core.EyrieResponse{ - Content: "Hi there!", - FinishReason: "stop", - }) - var content string - for event := range result.Events { - if event.Type == "content" { - content += event.Content - } - } - if content != "Hi there!" { - t.Fatalf("content = %q, want Hi there!", content) - } -} - -func TestNewStreamWithReasoningFallbackChatFirst(t *testing.T) { - t.Parallel() - primaryEvents := make(chan core.EyrieStreamEvent, 4) - primaryEvents <- core.EyrieStreamEvent{Type: "thinking", Thinking: "internal reasoning"} - primaryEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "end_turn"} - close(primaryEvents) - primary := llm.NewStreamResult(primaryEvents, "", func() {}) - - var chatCalled, streamCalled bool - fallback := protocolStreamFallback{ - chat: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.EyrieResponse, error) { - chatCalled = true - return &core.EyrieResponse{Content: "Hello from chat fallback!", FinishReason: "stop"}, nil - }, - stream: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, error) { - streamCalled = true - return nil, fmt.Errorf("stream fallback should not run") - }, - } - - result := newStreamWithReasoningFallback(context.Background(), nil, core.ChatOptions{}, primary, fallback) - var content string - for event := range result.Events { - switch event.Type { - case "thinking": - t.Fatal("primary reasoning must not leak before chat fallback succeeds") - case "content": - content += event.Content - } - } - if content != "Hello from chat fallback!" { - t.Fatalf("content = %q, want Hello from chat fallback!", content) - } - if !chatCalled { - t.Fatal("expected chat fallback to run") - } - if streamCalled { - t.Fatal("stream fallback must not run when chat fallback succeeds") - } -} - -func TestNewStreamWithReasoningFallbackStreamWhenChatEmpty(t *testing.T) { - t.Parallel() - primaryEvents := make(chan core.EyrieStreamEvent, 4) - primaryEvents <- core.EyrieStreamEvent{Type: "thinking", Thinking: "internal reasoning"} - primaryEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "end_turn"} - close(primaryEvents) - primary := llm.NewStreamResult(primaryEvents, "", func() {}) - - fallbackEvents := make(chan core.EyrieStreamEvent, 4) - fallbackEvents <- core.EyrieStreamEvent{Type: "content", Content: "stream answer"} - fallbackEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "stop"} - close(fallbackEvents) - - var chatCalled, streamCalled bool - fallback := protocolStreamFallback{ - chat: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.EyrieResponse, error) { - chatCalled = true - return &core.EyrieResponse{Thinking: "still thinking"}, nil - }, - stream: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, error) { - streamCalled = true - return llm.NewStreamResult(fallbackEvents, "", func() {}), nil - }, - } - - result := newStreamWithReasoningFallback(context.Background(), nil, core.ChatOptions{}, primary, fallback) - var content string - for event := range result.Events { - if event.Type == "content" { - content += event.Content - } - } - if content != "stream answer" { - t.Fatalf("content = %q, want stream answer", content) - } - if !chatCalled || !streamCalled { - t.Fatalf("chatCalled=%v streamCalled=%v, want both true", chatCalled, streamCalled) - } -} - -func TestProtocolRouterChatFallbackOnError(t *testing.T) { - t.Parallel() - openAI := NewOpenAIClient("key", "https://example/openai", nil) - anthropic := NewAnthropicClient("key", "https://example") - openAI.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return jsonResponse(http.StatusBadGateway, map[string]string{"error": "down"}), nil - })} - anthropic.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, map[string]any{ - "id": "msg_1", "type": "message", "role": "assistant", - "content": []map[string]string{{"type": "text", "text": "fallback"}}, - "stop_reason": "end_turn", - }), nil - })} - - router := ProtocolRouter{OpenAI: openAI, Anthropic: anthropic} - response, err := router.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ - Model: "test", MaxTokens: 16, - }, ChatProtocolCompletions, func(err error, _ *core.EyrieResponse) bool { - return err != nil - }) - if err != nil { - t.Fatalf("Chat: %v", err) - } - if response.Content != "fallback" { - t.Fatalf("content = %q, want fallback", response.Content) - } -} - -func TestProtocolRouterNoFallbackWhenNil(t *testing.T) { - t.Parallel() - openAI := NewOpenAIClient("key", "https://example/openai", nil) - openAI.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return jsonResponse(http.StatusBadGateway, map[string]string{"error": "down"}), nil - })} - router := ProtocolRouter{OpenAI: openAI} - _, err := router.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{ - Model: "test", MaxTokens: 16, - }, ChatProtocolCompletions, nil) - if err == nil { - t.Fatal("expected error without fallback") - } -} - -func TestStreamResultFromChat_FullResponse(t *testing.T) { - t.Parallel() - resp := &core.EyrieResponse{ - Thinking: "Let me think...", - Content: "Hello there!", - ToolCalls: []core.ToolCall{{Name: "get_weather", Arguments: map[string]interface{}{"city": "NYC"}}}, - Usage: &core.EyrieUsage{TotalTokens: 42}, - FinishReason: "", - } - result := streamResultFromChat(resp) - defer result.Close() - - var thinking, content string - var toolCalls int - var gotUsage, gotDone bool - var stopReason string - for evt := range result.Events { - switch evt.Type { - case "thinking": - thinking = evt.Thinking - case "content": - content = evt.Content - case "tool_call": - toolCalls++ - case "usage": - gotUsage = evt.Usage != nil - case "done": - gotDone = true - stopReason = evt.StopReason - } - } - if thinking != "Let me think..." { - t.Errorf("thinking = %q", thinking) - } - if content != "Hello there!" { - t.Errorf("content = %q", content) - } - if toolCalls != 1 { - t.Errorf("tool calls = %d", toolCalls) - } - if !gotUsage { - t.Error("expected usage event") - } - if !gotDone { - t.Error("expected done event") - } - if stopReason != "stop" { - t.Errorf("stop_reason = %q, expected 'stop'", stopReason) - } -} - -func TestStreamResultFromChat_NilResponse(t *testing.T) { - t.Parallel() - result := streamResultFromChat(nil) - defer result.Close() - - var eventCount int - for range result.Events { - eventCount++ - } - if eventCount != 0 { - t.Errorf("expected 0 events for nil response, got %d", eventCount) - } -} - -func TestNewStreamWithReasoningFallbackErrorFallback(t *testing.T) { - t.Parallel() - primaryEvents := make(chan core.EyrieStreamEvent, 4) - primaryEvents <- core.EyrieStreamEvent{Type: "thinking", Thinking: "internal reasoning"} - primaryEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "end_turn"} - close(primaryEvents) - primary := llm.NewStreamResult(primaryEvents, "", func() {}) - - fallbackEvents := make(chan core.EyrieStreamEvent, 4) - fallbackEvents <- core.EyrieStreamEvent{Type: "content", Content: "fallback content"} - fallbackEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "stop"} - close(fallbackEvents) - - fallback := protocolStreamFallback{ - chat: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.EyrieResponse, error) { - return nil, fmt.Errorf("chat failed") - }, - stream: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, error) { - return llm.NewStreamResult(fallbackEvents, "", func() {}), nil - }, - } - - result := newStreamWithReasoningFallback(context.Background(), nil, core.ChatOptions{}, primary, fallback) - var content string - var gotError bool - for event := range result.Events { - if event.Type == "content" { - content += event.Content - } - if event.Type == "error" { - gotError = true - } - } - if content != "fallback content" { - t.Fatalf("content = %q, want fallback content", content) - } - if gotError { - t.Fatal("did not expect error event") - } -} - -func TestNewStreamWithReasoningFallbackErrorFallbackFails(t *testing.T) { - t.Parallel() - primaryEvents := make(chan core.EyrieStreamEvent, 4) - primaryEvents <- core.EyrieStreamEvent{Type: "thinking", Thinking: "internal reasoning"} - primaryEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "end_turn"} - close(primaryEvents) - primary := llm.NewStreamResult(primaryEvents, "", func() {}) - - fallback := protocolStreamFallback{ - chat: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.EyrieResponse, error) { - return nil, fmt.Errorf("chat failed") - }, - stream: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, error) { - return nil, fmt.Errorf("stream also failed") - }, - } - - result := newStreamWithReasoningFallback(context.Background(), nil, core.ChatOptions{}, primary, fallback) - var gotError bool - var errorMsg string - for event := range result.Events { - if event.Type == "error" { - gotError = true - errorMsg = event.Error - } - } - if !gotError { - t.Fatal("expected error event when both fallbacks fail") - } - if errorMsg != "stream also failed" { - t.Errorf("error = %q, want stream also failed", errorMsg) - } -} - -func TestNewStreamWithReasoningFallbackNonReasoningError(t *testing.T) { - t.Parallel() - primaryEvents := make(chan core.EyrieStreamEvent, 4) - primaryEvents <- core.EyrieStreamEvent{Type: "thinking", Thinking: "internal reasoning"} - primaryEvents <- core.EyrieStreamEvent{Type: "error", Error: "connection refused"} - primaryEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "end_turn"} - close(primaryEvents) - primary := llm.NewStreamResult(primaryEvents, "", func() {}) - - var fallbackCalled bool - fallback := protocolStreamFallback{ - chat: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.EyrieResponse, error) { - fallbackCalled = true - return &core.EyrieResponse{Content: "fallback"}, nil - }, - stream: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, error) { - fallbackCalled = true - return nil, fmt.Errorf("should not reach") - }, - } - - result := newStreamWithReasoningFallback(context.Background(), nil, core.ChatOptions{}, primary, fallback) - var gotError bool - for event := range result.Events { - if event.Type == "error" { - gotError = true - } - } - if !gotError { - t.Fatal("expected error event for non-reasoning error") - } - if fallbackCalled { - t.Fatal("fallback should not be called for non-reasoning error") - } -} - -func TestNewStreamWithReasoningFallbackNormalContent(t *testing.T) { - t.Parallel() - primaryEvents := make(chan core.EyrieStreamEvent, 4) - primaryEvents <- core.EyrieStreamEvent{Type: "thinking", Thinking: "internal reasoning"} - primaryEvents <- core.EyrieStreamEvent{Type: "content", Content: "actual answer"} - primaryEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "stop"} - close(primaryEvents) - primary := llm.NewStreamResult(primaryEvents, "", func() {}) - - var fallbackCalled bool - fallback := protocolStreamFallback{ - chat: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.EyrieResponse, error) { - fallbackCalled = true - return &core.EyrieResponse{Content: "fallback"}, nil - }, - stream: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, error) { - fallbackCalled = true - return nil, fmt.Errorf("should not reach") - }, - } - - result := newStreamWithReasoningFallback(context.Background(), nil, core.ChatOptions{}, primary, fallback) - var content string - for event := range result.Events { - if event.Type == "content" { - content += event.Content - } - } - if content != "actual answer" { - t.Fatalf("content = %q, want actual answer", content) - } - if fallbackCalled { - t.Fatal("fallback should not be called when content is present") - } -} - -func TestNewStreamWithReasoningFallbackToolCalls(t *testing.T) { - t.Parallel() - primaryEvents := make(chan core.EyrieStreamEvent, 4) - primaryEvents <- core.EyrieStreamEvent{Type: "thinking", Thinking: "internal reasoning"} - primaryEvents <- core.EyrieStreamEvent{Type: "tool_call", ToolCall: &core.ToolCall{Name: "test_tool"}} - primaryEvents <- core.EyrieStreamEvent{Type: "done", StopReason: "tool_calls"} - close(primaryEvents) - primary := llm.NewStreamResult(primaryEvents, "", func() {}) - - var fallbackCalled bool - fallback := protocolStreamFallback{ - chat: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.EyrieResponse, error) { - fallbackCalled = true - return &core.EyrieResponse{Content: "fallback"}, nil - }, - stream: func(context.Context, []core.EyrieMessage, core.ChatOptions) (*core.StreamResult, error) { - fallbackCalled = true - return nil, fmt.Errorf("should not reach") - }, - } - - result := newStreamWithReasoningFallback(context.Background(), nil, core.ChatOptions{}, primary, fallback) - var gotToolCall bool - for event := range result.Events { - if event.Type == "tool_call" { - gotToolCall = true - } - } - if !gotToolCall { - t.Fatal("expected tool_call event to be forwarded") - } - if fallbackCalled { - t.Fatal("fallback should not be called when tool calls are present") - } -} - -func TestIsReasoningOnlyStreamDiagnostic(t *testing.T) { - t.Parallel() - tests := []struct { - name string - message string - want bool - }{ - {"error_only_reasoning", "error_only_reasoning", true}, - {"reasoning tokens but no answer", "reasoning tokens but no answer", true}, - {"case insensitive", "ERROR_ONLY_REASONING", true}, - {"with whitespace", " error_only_reasoning ", true}, - {"normal message", "Hello world", false}, - {"empty", "", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := isReasoningOnlyStreamDiagnostic(tt.message) - if got != tt.want { - t.Errorf("isReasoningOnlyStreamDiagnostic(%q) = %v, want %v", tt.message, got, tt.want) - } - }) - } -} diff --git a/client/adapters/test_helpers_test.go b/client/adapters/test_helpers_test.go index 4f7a2cb6..1805eae4 100644 --- a/client/adapters/test_helpers_test.go +++ b/client/adapters/test_helpers_test.go @@ -9,12 +9,6 @@ import ( "testing" ) -type transportError struct{ msg string } - -func (e *transportError) Error() string { return e.msg } -func (e *transportError) Timeout() bool { return false } -func (e *transportError) Temporary() bool { return true } - func testLogger(t *testing.T) *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } diff --git a/client/adapters/zai.go b/client/adapters/zai.go index 9cf8dd48..afc29cb6 100644 --- a/client/adapters/zai.go +++ b/client/adapters/zai.go @@ -2,123 +2,46 @@ package adapters import ( "context" - "errors" - "log/slog" - "net/http" "strings" "github.com/GrayCodeAI/eyrie/client/core" - - "github.com/GrayCodeAI/eyrie/types" ) -// ZAIClient uses the OpenAI-compatible endpoint (paas/v4 or coding/paas/v4) first, -// with Anthropic-compatible fallback (/api/anthropic) on retriable errors. -// This provides proper separation for General vs Coding Plan while giving -// both protocol surfaces (exactly as Xiaomi MiMo does for its plans). +// ZAIClient uses the official OpenAI-compatible Z.AI surface only +// (paas/v4 or coding/paas/v4). type ZAIClient struct { - router ProtocolRouter + openai *OpenAIClient providerID string - logger *slog.Logger } -// NewZAIClient builds a Z.AI dual-protocol client for a given plan/region gateway. +// NewZAIClient builds an OpenAI-compatible Z.AI client for a plan/region gateway. // openAIBase should be the resolved general or coding paas base. -// anthropicBase should be the resolved /api/anthropic (global or cn). -// The same apiKey is used for both sides; the plan subscription attached to the key -// controls quota/billing when using the coding path. -func NewZAIClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, providerID string, opts ...core.ClientOption) *ZAIClient { +func NewZAIClient(apiKey, openAIBase string, compat *OpenAICompatConfig, providerID string, opts ...core.ClientOption) *ZAIClient { openAIBase = strings.TrimRight(strings.TrimSpace(openAIBase), "/") - anthropicBase = strings.TrimRight(strings.TrimSpace(anthropicBase), "/") - zaiOpts := append([]core.ClientOption{core.WithProviderName(providerID)}, opts...) - o := NewOpenAIClient(apiKey, openAIBase, compat, zaiOpts...) - - var a *AnthropicClient - if anthropicBase != "" { - a = NewAnthropicClient(apiKey, anthropicBase, zaiOpts...) - } - return &ZAIClient{ - router: ProtocolRouter{OpenAI: o, Anthropic: a}, + openai: NewOpenAIClient(apiKey, openAIBase, compat, zaiOpts...), providerID: providerID, - logger: slog.Default(), } } func (c *ZAIClient) Name() string { - if c.router.OpenAI != nil { - return c.router.OpenAI.Name() + if c.openai != nil { + return c.openai.Name() } return c.providerID } func (c *ZAIClient) Chat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.EyrieResponse, error) { - return c.router.Chat(ctx, messages, opts, ChatProtocolCompletions, func(err error, _ *core.EyrieResponse) bool { - if err != nil && c.router.Anthropic != nil && zaiFallbackChatError(err) { - c.logger.Info("Z.AI: OpenAI endpoint failed; retrying via Anthropic compatibility", - "provider", c.providerID, "error", err) - return true - } - return false - }) + return c.openai.Chat(ctx, messages, opts) } func (c *ZAIClient) StreamChat(ctx context.Context, messages []core.EyrieMessage, opts core.ChatOptions) (*core.StreamResult, error) { - return c.router.StreamChat(ctx, messages, opts, ProtocolStreamConfig{ - Primary: ChatProtocolCompletions, - FallbackOnError: func(err error) bool { - if c.router.Anthropic != nil && zaiFallbackChatError(err) { - c.logger.Info("Z.AI: OpenAI stream failed; retrying via Anthropic compatibility", - "provider", c.providerID, "error", err) - return true - } - return false - }, - }) + return c.openai.StreamChat(ctx, messages, opts) } func (c *ZAIClient) Ping(ctx context.Context) error { - if err := c.router.OpenAI.Ping(ctx); err == nil { - return nil - } else if c.router.Anthropic == nil || !zaiRetryableChatError(err) { - return err - } - return c.router.Anthropic.Ping(ctx) -} - -func zaiFallbackChatError(err error) bool { - if zaiRetryableChatError(err) { - return true - } - if err == nil { - return false - } - msg := strings.ToLower(err.Error()) - // Common Z.AI / GLM specific transient or format issues seen in the wild - // (similar to reasoning_content or param problems on other compat layers). - return strings.Contains(msg, "param incorrect") || - strings.Contains(msg, "invalid format") || - strings.Contains(msg, "reasoning_content") || - (strings.Contains(msg, "http 400") && strings.Contains(msg, "zai")) -} - -func zaiRetryableChatError(err error) bool { - if err == nil { - return false - } - // Z.AI-specific: check HTTP status codes - msg := err.Error() - if n := parseHTTPStatusFromError(msg); n > 0 { - return n >= 500 || n == http.StatusUnauthorized || n == http.StatusForbidden - } - // Structured path: trust core.EyrieError's IsRetriable - var eyrieErr *core.EyrieError - if errors.As(err, &eyrieErr) { - return eyrieErr.IsRetriable() - } - // Conservative: only retry on explicitly transient errors - return types.IsTransient(err) + return c.openai.Ping(ctx) } var _ core.Provider = (*ZAIClient)(nil) diff --git a/client/adapters/zai_test.go b/client/adapters/zai_test.go index a35a1077..9317244d 100644 --- a/client/adapters/zai_test.go +++ b/client/adapters/zai_test.go @@ -2,292 +2,64 @@ package adapters import ( "context" - "errors" + "encoding/json" "io" "net/http" + "net/http/httptest" "strings" "testing" + "time" "github.com/GrayCodeAI/eyrie/client/core" - "github.com/GrayCodeAI/eyrie/types" ) -func TestNewZAIClient_WithAnthropicFallback(t *testing.T) { +func TestNewZAIClient_OpenAIOnly(t *testing.T) { t.Parallel() - client := NewZAIClient("zai-key", "https://zai.example/paas/v4", "https://zai.example/api/anthropic", nil, "zai_payg") - if client == nil { - t.Fatal("NewZAIClient returned nil") - } - if client.router.OpenAI == nil { + client := NewZAIClient("zai-key", "https://zai.example/paas/v4", &ZAICompat, "zai_payg") + if client == nil || client.openai == nil { t.Fatal("expected OpenAI client") } - if client.router.Anthropic == nil { - t.Fatal("expected Anthropic client for fallback") - } -} - -func TestNewZAIClient_WithoutAnthropicFallback(t *testing.T) { - t.Parallel() - client := NewZAIClient("zai-key", "https://zai.example/paas/v4", "", nil, "zai_coding") - if client.router.Anthropic != nil { - t.Fatal("expected no Anthropic client when anthropicBase is empty") - } -} - -func TestZAIClient_Name(t *testing.T) { - t.Parallel() - client := NewZAIClient("key", "https://zai.example/paas/v4", "", nil, "zai_payg") if client.Name() == "" { - t.Fatal("expected non-empty Name") - } -} - -func TestZAIClient_ChatOpenAISuccess(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, map[string]any{ - "id": "chatcmpl-1", "object": "chat.completion", - "choices": []map[string]any{{"message": map[string]string{"role": "assistant", "content": "Z.AI Hello!"}, "finish_reason": "stop"}}, - "usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}, - }), nil - }) - - client := NewZAIClient("key", "https://zai.example/paas/v4", "", nil, "zai_payg") - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - - resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "glm-4", MaxTokens: 256}) - if err != nil { - t.Fatalf("Chat: %v", err) - } - if resp.Content != "Z.AI Hello!" { - t.Errorf("content = %q, want Z.AI Hello!", resp.Content) - } -} - -func TestZAIClient_ChatFallbackToAnthropic(t *testing.T) { - t.Parallel() - var paths []string - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - paths = append(paths, req.URL.Path) - if strings.HasSuffix(req.URL.Path, "/chat/completions") { - return jsonResponse(http.StatusServiceUnavailable, map[string]any{ - "error": map[string]string{"message": "overloaded"}, - }), nil - } - if strings.HasSuffix(req.URL.Path, "/messages") { - return jsonResponse(http.StatusOK, map[string]any{ - "id": "msg_1", "type": "message", "role": "assistant", - "content": []map[string]string{{"type": "text", "text": "Hello from Z.AI Anthropic!"}}, - "stop_reason": "end_turn", - "usage": map[string]int{"input_tokens": 1, "output_tokens": 2}, - }), nil - } - return jsonResponse(http.StatusOK, map[string]any{ - "choices": []map[string]any{{"message": map[string]string{"role": "assistant", "content": "OK"}, "finish_reason": "stop"}}, - }), nil - }) - - client := NewZAIClient("key", "https://zai.example/paas/v4", "https://zai.example/api/anthropic", nil, "zai_payg") - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - client.router.Anthropic.httpClient = &http.Client{Transport: transport} - - resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "glm-4", MaxTokens: 256}) - if err != nil { - t.Fatalf("Chat: %v", err) - } - if resp.Content != "Hello from Z.AI Anthropic!" { - t.Errorf("content = %q, want Hello from Z.AI Anthropic!", resp.Content) - } - if len(paths) < 2 { - t.Fatalf("expected anthropic fallback, got %v paths", len(paths)) - } -} - -func TestZAIClient_StreamChatFallbackToAnthropic(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - if strings.HasSuffix(req.URL.Path, "/chat/completions") { - return jsonResponse(http.StatusServiceUnavailable, map[string]any{ - "error": map[string]string{"message": "overloaded"}, - }), nil - } - if strings.HasSuffix(req.URL.Path, "/messages") { - body := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":10}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello Z.AI stream!\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" - return &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"text/event-stream"}}, - Body: io.NopCloser(strings.NewReader(body)), - }, nil - } - return jsonResponse(http.StatusOK, map[string]any{ - "choices": []map[string]any{{"message": map[string]string{"role": "assistant", "content": "OK"}, "finish_reason": "stop"}}, - }), nil - }) - - client := NewZAIClient("key", "https://zai.example/paas/v4", "https://zai.example/api/anthropic", nil, "zai_payg") - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - client.router.Anthropic.httpClient = &http.Client{Transport: transport} - - result, err := client.StreamChat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "glm-4", MaxTokens: 256}) - if err != nil { - t.Fatalf("StreamChat: %v", err) - } - defer result.Close() - - var content string - for event := range result.Events { - if event.Type == "error" { - t.Fatalf("unexpected stream error: %s", event.Error) - } - if event.Type == "content" { - content += event.Content - } - } - if content != "Hello Z.AI stream!" { - t.Errorf("content = %q, want Hello Z.AI stream!", content) - } -} - -func TestZAIClient_PingOpenAISuccess(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusOK, map[string]string{"status": "ok"}), nil - }) - - client := NewZAIClient("key", "https://zai.example/paas/v4", "", nil, "zai_payg") - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - - err := client.Ping(context.Background()) - if err != nil { - t.Fatalf("Ping: %v", err) - } -} - -func TestZAIClient_PingFallbackToAnthropic(t *testing.T) { - t.Parallel() - transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - if strings.HasSuffix(req.URL.Path, "/models") && strings.Contains(req.URL.String(), "paas") { - return nil, &transportError{msg: "connection refused"} - } - return jsonResponse(http.StatusOK, map[string]string{"status": "ok"}), nil - }) - - client := NewZAIClient("key", "https://zai.example/paas/v4", "https://zai.example/api/anthropic", nil, "zai_payg") - client.router.OpenAI.httpClient = &http.Client{Transport: transport} - client.router.Anthropic.httpClient = &http.Client{Transport: transport} - - err := client.Ping(context.Background()) - if err != nil { - t.Fatalf("Ping fallback: %v", err) + t.Fatal("expected provider name") } } -func TestZaiFallbackChatError(t *testing.T) { +func TestZAIClient_ChatUsesOpenAIPath(t *testing.T) { t.Parallel() - tests := []struct { - name string - err error - want bool - }{ - {"nil error", nil, false}, - {"retriable eyrie error", &core.EyrieError{StatusCode: 503}, true}, - {"non-retriable eyrie error", &core.EyrieError{StatusCode: 400}, false}, - {"param incorrect", errors.New("param incorrect"), true}, - {"invalid format", errors.New("invalid format"), true}, - {"reasoning_content", errors.New("reasoning_content"), true}, - {"http 400 with zai", errors.New("http 400 zai error"), true}, - {"http 400 without zai", errors.New("http 400 generic"), false}, - {"generic error", errors.New("something else"), false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := zaiFallbackChatError(tt.err) - if got != tt.want { - t.Errorf("zaiFallbackChatError(%v) = %v, want %v", tt.err, got, tt.want) - } + var path string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "chat", + "choices": []map[string]interface{}{ + {"message": map[string]string{"role": "assistant", "content": "ok"}, "finish_reason": "stop"}, + }, }) - } -} + })) + defer server.Close() -func TestZaiRetryableChatError(t *testing.T) { - t.Parallel() - tests := []struct { - name string - err error - want bool - }{ - {"nil error", nil, false}, - {"http 500 in message", errors.New("HTTP 500"), true}, - {"http 401 in message", errors.New("HTTP 401"), true}, - {"http 403 in message", errors.New("HTTP 403"), true}, - {"http 400 in message", errors.New("HTTP 400"), false}, - {"http 200 in message", errors.New("HTTP 200"), false}, - {"retriable eyrie error", &core.EyrieError{StatusCode: 503}, true}, - {"non-retriable eyrie error", &core.EyrieError{StatusCode: 400}, false}, - {"transient error", &transportError{msg: "timeout"}, true}, - {"generic error", errors.New("something"), false}, + client := NewZAIClient("key", server.URL, &ZAICompat, "zai_payg", core.WithRetry(core.NewRetryConfig(0, 0, 0))) + resp, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "hi"}}, core.ChatOptions{Model: "glm-5.1", MaxTokens: 16}) + if err != nil { + t.Fatal(err) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := zaiRetryableChatError(tt.err) - if got != tt.want { - t.Errorf("zaiRetryableChatError(%v) = %v, want %v", tt.err, got, tt.want) - } - }) + if resp == nil || resp.Content != "ok" { + t.Fatalf("resp = %+v", resp) } -} - -func TestZAIClient_Name_NilOpenAI(t *testing.T) { - t.Parallel() - client := &ZAIClient{providerID: "zai_custom"} - if client.Name() != "zai_custom" { - t.Errorf("Name() = %q, want zai_custom", client.Name()) + if !strings.Contains(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) } } -func TestZAIClient_Chat_NoFallbackOnNonRetriable(t *testing.T) { +func TestZAIClient_Ping(t *testing.T) { t.Parallel() - openAITransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"message": "some other error"}}), nil - }) - client := NewZAIClient("key", "https://zai.example/paas/v4", "", nil, "zai_payg") - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: openAITransport} - - _, err := client.Chat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "glm-4"}) - if err == nil { - t.Fatal("expected error without fallback") - } -} - -func TestZAIClient_StreamChat_NoFallbackOnNonRetriable(t *testing.T) { - t.Parallel() - openAITransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"message": "some other error"}}), nil - }) - client := NewZAIClient("key", "https://zai.example/paas/v4", "", nil, "zai_payg") - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: openAITransport} - - _, err := client.StreamChat(context.Background(), []core.EyrieMessage{{Role: "user", Content: "Hi"}}, core.ChatOptions{Model: "glm-4"}) - if err == nil { - t.Fatal("expected error without fallback") - } -} - -func TestZAIClient_Ping_NoAnthropicNoFallback(t *testing.T) { - t.Parallel() - openAITransport := roundTripFunc(func(req *http.Request) (*http.Response, error) { - return jsonResponse(http.StatusUnauthorized, map[string]any{"error": map[string]string{"message": "unauthorized"}}), nil - }) - client := NewZAIClient("key", "https://zai.example/paas/v4", "", nil, "zai_payg") - client.router.OpenAI.SetRetry(core.RetryConfig{RetryConfig: types.RetryConfig{MaxRetries: 0}}) - client.router.OpenAI.httpClient = &http.Client{Transport: openAITransport} - - err := client.Ping(context.Background()) - if err == nil { - t.Fatal("expected error without anthropic fallback client") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[]}`) + })) + defer server.Close() + client := NewZAIClient("key", server.URL, &ZAICompat, "zai_coding", core.WithTimeout(2*time.Second)) + if err := client.Ping(context.Background()); err != nil { + t.Fatal(err) } } diff --git a/client/aliases.go b/client/aliases.go index bdf68791..518af17a 100644 --- a/client/aliases.go +++ b/client/aliases.go @@ -278,20 +278,10 @@ type ( OpenCodeGoClient = adapters.OpenCodeGoClient // PoolsideClient implements Poolside reasoning-only stream recovery. PoolsideClient = adapters.PoolsideClient - // ProtocolRouter routes between OpenAI and Anthropic protocols. - ProtocolRouter = adapters.ProtocolRouter - // ProtocolStreamConfig controls streaming across two protocols. - ProtocolStreamConfig = adapters.ProtocolStreamConfig // TokenCountResult holds token counting results. TokenCountResult = adapters.TokenCountResult ) -// Adapter protocol constants. -const ( - ChatProtocolCompletions = adapters.ChatProtocolCompletions - ChatProtocolMessages = adapters.ChatProtocolMessages -) - // Adapter constructors. func NewAnthropicClient(apiKey, baseURL string, opts ...ClientOption) *AnthropicClient { return adapters.NewAnthropicClient(apiKey, baseURL, opts...) @@ -321,16 +311,16 @@ func NewVertexClient(projectID, region, token string) *VertexClient { return adapters.NewVertexClient(projectID, region, token) } -func NewDeepSeekClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, opts ...ClientOption) *DeepSeekClient { - return adapters.NewDeepSeekClient(apiKey, openAIBase, anthropicBase, compat, opts...) +func NewDeepSeekClient(apiKey, openAIBase string, compat *OpenAICompatConfig, opts ...ClientOption) *DeepSeekClient { + return adapters.NewDeepSeekClient(apiKey, openAIBase, compat, opts...) } -func NewZAIClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, providerID string, opts ...ClientOption) *ZAIClient { - return adapters.NewZAIClient(apiKey, openAIBase, anthropicBase, compat, providerID, opts...) +func NewZAIClient(apiKey, openAIBase string, compat *OpenAICompatConfig, providerID string, opts ...ClientOption) *ZAIClient { + return adapters.NewZAIClient(apiKey, openAIBase, compat, providerID, opts...) } -func NewMiMoClient(apiKey, openAIBase, anthropicBase string, compat *OpenAICompatConfig, providerID string, opts ...ClientOption) *MiMoClient { - return adapters.NewMiMoClient(apiKey, openAIBase, anthropicBase, compat, providerID, opts...) +func NewMiMoClient(apiKey, openAIBase string, compat *OpenAICompatConfig, providerID string, opts ...ClientOption) *MiMoClient { + return adapters.NewMiMoClient(apiKey, openAIBase, compat, providerID, opts...) } func NewOpenCodeGoClient(apiKey, baseURL string, opts ...ClientOption) *OpenCodeGoClient { @@ -381,8 +371,6 @@ var ( geminiSharedParserEnvVar = adapters.GeminiSharedParserEnvVar processGeminiStream = adapters.ProcessGeminiStream mimoRetryableChatError = adapters.MimoRetryableChatError - mimoFallbackChatError = adapters.MimoFallbackChatError - oaCompatUnsupportedError = adapters.OACompatUnsupportedError CoreProviders = adapters.CoreProviders OpenAICompatibleProviders = adapters.OpenAICompatibleProviders sha256Hex = adapters.Sha256Hex diff --git a/client/compat.go b/client/compat.go index 6a82f164..1d562308 100644 --- a/client/compat.go +++ b/client/compat.go @@ -8,6 +8,8 @@ type OpenAICompatConfig = adapters.OpenAICompatConfig // Per-provider compat configs. var ( OpenAICompat = adapters.OpenAICompat + AgnesCompat = adapters.AgnesCompat + LongCatCompat = adapters.LongCatCompat GrokCompat = adapters.GrokCompat OpenRouterCompat = adapters.OpenRouterCompat GeminiCompat = adapters.GeminiCompat @@ -25,4 +27,5 @@ var ( VertexCompat = adapters.VertexCompat DeepSeekCompat = adapters.DeepSeekCompat ConcentrateCompat = adapters.ConcentrateCompat + MiniMaxCompat = adapters.MiniMaxCompat ) diff --git a/client/compat_test.go b/client/compat_test.go index 2b5c7450..2d070c04 100644 --- a/client/compat_test.go +++ b/client/compat_test.go @@ -113,19 +113,29 @@ func TestCompatMatrixThinkingFormatValues(t *testing.T) { {&OpenRouterCompat, "openrouter"}, {&ZAICompat, "zai"}, {&OpenCodeGoCompat, "openrouter"}, + {&AgnesCompat, "agnes"}, + {&LongCatCompat, "longcat"}, + {&KimiCompat, "kimi"}, + {&DeepSeekCompat, "deepseek"}, + {&XiaomiCompat, "xiaomi"}, + {&MiniMaxCompat, "minimax"}, } for _, tc := range tests { if tc.compat.ThinkingFormat != tc.format { t.Errorf("ThinkingFormat = %q, want %q", tc.compat.ThinkingFormat, tc.format) } } + for _, c := range []*OpenAICompatConfig{&LongCatCompat, &KimiCompat, &DeepSeekCompat, &XiaomiCompat, &MiniMaxCompat} { + if !c.DefaultDisableThinking { + t.Fatalf("%q DefaultDisableThinking should be true", c.ThinkingFormat) + } + } // Providers without explicit thinking format should have empty string. noFormat := []*OpenAICompatConfig{ &OpenAICompat, &GrokCompat, &GeminiCompat, &CanopyWaveCompat, &OllamaCompat, - &KimiCompat, &XiaomiCompat, &AzureCompat, - &BedrockCompat, &VertexCompat, + &AzureCompat, &BedrockCompat, &VertexCompat, } for _, c := range noFormat { if c.ThinkingFormat != "" { diff --git a/client/mimo_test.go b/client/mimo_test.go index 453f86e6..6e30d7e3 100644 --- a/client/mimo_test.go +++ b/client/mimo_test.go @@ -30,16 +30,6 @@ func TestMimoRetryableChatError_UsesXiaomiHelper(t *testing.T) { } } -func TestMimoFallbackChatError_ParamIncorrect(t *testing.T) { - err := errors.New("eyrie: xiaomi_mimo_token_plan API error (request_id=): : Param Incorrect") - if !mimoFallbackChatError(err) { - t.Fatal("expected Param Incorrect to fallback to Anthropic compatibility") - } - if mimoFallbackChatError(errors.New("eyrie: openai API error: invalid model")) { - t.Fatal("non-MiMo unrelated errors should not trigger fallback") - } -} - func TestGetOrCreateProvider_XiaomiTokenPlanUsesMimoBase(t *testing.T) { t.Setenv("HAWK_CONFIG_DIR", t.TempDir()) if err := eyriecfg.SaveProviderConfig(&eyriecfg.ProviderConfig{ diff --git a/client/opencodego_test.go b/client/opencodego_test.go index e0d4e24b..92e9b5dc 100644 --- a/client/opencodego_test.go +++ b/client/opencodego_test.go @@ -1,7 +1,6 @@ package client import ( - "fmt" "testing" "github.com/GrayCodeAI/eyrie/catalog/opencodego" @@ -33,21 +32,3 @@ func TestOpenCodeGoAnthropicBase(t *testing.T) { t.Fatalf("base = %q, want https://opencode.ai/zen/go", got) } } - -func TestOpenCodeGoOACompatUnsupportedError(t *testing.T) { - t.Parallel() - tests := []struct { - err error - want bool - }{ - {nil, false}, - {fmt.Errorf("status=401 unauthorized"), true}, - {fmt.Errorf("oa-compat not supported"), true}, - {fmt.Errorf("HTTP 400 bad request"), false}, - } - for _, tc := range tests { - if got := oaCompatUnsupportedError(tc.err); got != tc.want { - t.Errorf("OACompatUnsupportedError(%v) = %v, want %v", tc.err, got, tc.want) - } - } -} diff --git a/client/protocol_router_test.go b/client/protocol_router_test.go deleted file mode 100644 index 23969845..00000000 --- a/client/protocol_router_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package client - -import ( - "testing" -) - -func TestAnthropicBaseFromOpenAIV1(t *testing.T) { - t.Parallel() - if got := AnthropicBaseFromOpenAIV1("https://example.com/zen/go/v1"); got != "https://example.com/zen/go" { - t.Fatalf("got %q", got) - } - if got := AnthropicBaseFromOpenAIV1("https://example.com/zen/go"); got != "https://example.com/zen/go" { - t.Fatalf("got %q", got) - } -} diff --git a/client/provider_policy.go b/client/provider_policy.go index f619835b..ed07d090 100644 --- a/client/provider_policy.go +++ b/client/provider_policy.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/GrayCodeAI/eyrie/client/core" - "github.com/GrayCodeAI/eyrie/config" ) // ApplyProviderChatDefaults applies provider policy that host applications @@ -14,8 +13,25 @@ func ApplyProviderChatDefaults(provider string, opts ChatOptions) ChatOptions { if strings.EqualFold(strings.TrimSpace(provider), "anthropic") { opts.EnableCaching = true } - if !config.IsZAIProvider(provider) { - opts.GLMThinkingEnabled = nil + opts = NormalizeThinkingOptions(opts) + switch strings.ToLower(strings.TrimSpace(provider)) { + case "zai_payg", "zai_coding", "agnes", "openrouter", "opencodego", "anthropic": + // Keep ThinkingEnabled — wire encoding comes from each provider's ThinkingFormat + // (or Anthropic resolveThinking). + case "longcat", "kimi", "deepseek", "xiaomi_mimo", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", + "minimax_payg", "minimax_token_plan": + // These providers enable thinking when the field is omitted. Default off so + // simple chat does not burn max_tokens on reasoning_content alone. + if EffectiveThinkingEnabled(opts) == nil { + disabled := false + opts.ThinkingEnabled = &disabled + opts.GLMThinkingEnabled = &disabled + } + default: + if !ProviderSupportsThinkingToggle(provider) { + opts.ThinkingEnabled = nil + opts.GLMThinkingEnabled = nil + } } return opts } diff --git a/client/provider_policy_test.go b/client/provider_policy_test.go index f3df322e..c5f9f72e 100644 --- a/client/provider_policy_test.go +++ b/client/provider_policy_test.go @@ -15,11 +15,32 @@ func TestApplyProviderChatDefaults(t *testing.T) { t.Fatal("non-Anthropic provider enabled caching") } enabled := true - if opts := ApplyProviderChatDefaults("zai_payg", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled == nil { + if opts := ApplyProviderChatDefaults("zai_payg", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil { t.Fatal("Z.AI thinking preference was removed") } - if opts := ApplyProviderChatDefaults("openai", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled != nil { - t.Fatal("non-Z.AI thinking preference was retained") + if opts := ApplyProviderChatDefaults("longcat", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil || !*opts.ThinkingEnabled { + t.Fatal("LongCat explicit thinking preference was removed") + } + if opts := ApplyProviderChatDefaults("longcat", ChatOptions{}); opts.ThinkingEnabled == nil || *opts.ThinkingEnabled { + t.Fatal("LongCat should default ThinkingEnabled=false when unset") + } + if opts := ApplyProviderChatDefaults("kimi", ChatOptions{}); opts.ThinkingEnabled == nil || *opts.ThinkingEnabled { + t.Fatal("Kimi should default ThinkingEnabled=false when unset") + } + if opts := ApplyProviderChatDefaults("deepseek", ChatOptions{}); opts.ThinkingEnabled == nil || *opts.ThinkingEnabled { + t.Fatal("DeepSeek should default ThinkingEnabled=false when unset") + } + if opts := ApplyProviderChatDefaults("agnes", ChatOptions{GLMThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil { + t.Fatal("Agnes should normalize deprecated GLMThinkingEnabled alias") + } + if opts := ApplyProviderChatDefaults("openrouter", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil { + t.Fatal("OpenRouter should keep ThinkingEnabled") + } + if opts := ApplyProviderChatDefaults("anthropic", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil { + t.Fatal("Anthropic should keep ThinkingEnabled") + } + if opts := ApplyProviderChatDefaults("openai", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled != nil || opts.GLMThinkingEnabled != nil { + t.Fatal("non-thinking provider thinking preference was retained") } } diff --git a/client/provider_registry.go b/client/provider_registry.go index 4223c3e1..fa21c45e 100644 --- a/client/provider_registry.go +++ b/client/provider_registry.go @@ -132,8 +132,7 @@ func (c *EyrieClient) getOrCreateProvider(providerName string) (Provider, error) if err != nil { return nil, err } - anthropicBase := config.ResolveZAIAnthropicBase(providerCfg) - p = adapters.NewZAIClient(apiKey, openAIBase, anthropicBase, info.Compat, providerName) + p = adapters.NewZAIClient(apiKey, openAIBase, info.Compat, providerName) break } if config.IsXiaomiMimoProvider(providerName) { @@ -142,11 +141,7 @@ func (c *EyrieClient) getOrCreateProvider(providerName string) (Provider, error) if err != nil { return nil, err } - anthropicBase, err := config.ResolveXiaomiAnthropicBase(providerName, providerCfg) - if err != nil { - return nil, err - } - p = adapters.NewMiMoClient(apiKey, openAIBase, anthropicBase, info.Compat, providerName) + p = adapters.NewMiMoClient(apiKey, openAIBase, info.Compat, providerName) break } if providerName == "opencodego" { diff --git a/client/thinking_policy.go b/client/thinking_policy.go new file mode 100644 index 00000000..acbc25dd --- /dev/null +++ b/client/thinking_policy.go @@ -0,0 +1,74 @@ +package client + +import ( + "strings" + + "github.com/GrayCodeAI/eyrie/client/core" +) + +// ProviderThinkingFormat is the wire encoding for extended thinking / reasoning. +// Each provider that supports a host-controlled toggle has its own format; the +// host preference is always the generic ChatOptions.ThinkingEnabled (with +// deprecated GLMThinkingEnabled as a Z.AI-era alias). +// +// Formats (from official provider docs): +// +// zai / longcat / kimi / deepseek / xiaomi → thinking={"type":"enabled"|"disabled"} +// minimax → thinking={"type":"adaptive"|"disabled"} +// agnes → chat_template_kwargs.enable_thinking +// qwen → enable_thinking (top-level) +// openrouter → reasoning={enabled|effort:"none"} +const ( + ThinkingFormatNone = "" + ThinkingFormatZAI = "zai" + ThinkingFormatLongCat = "longcat" + ThinkingFormatKimi = "kimi" + ThinkingFormatDeepSeek = "deepseek" + ThinkingFormatXiaomi = "xiaomi" + ThinkingFormatMiniMax = "minimax" // MiniMax OpenAI: thinking={"type":"adaptive"|"disabled"} + ThinkingFormatAgnes = "agnes" + ThinkingFormatQwen = "qwen" + ThinkingFormatOpenRouter = "openrouter" +) + +// ProviderSupportsThinkingToggle reports whether the provider honors the generic +// ThinkingEnabled host preference on the wire. +func ProviderSupportsThinkingToggle(provider string) bool { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "zai_payg", "zai_coding", + "longcat", + "agnes", + "kimi", + "deepseek", + "xiaomi_mimo", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", + "minimax_payg", "minimax_token_plan", + "openrouter", + "opencodego", + "anthropic": + return true + default: + return false + } +} + +// EffectiveThinkingEnabled returns the host thinking preference. +// ThinkingEnabled is the standard field; GLMThinkingEnabled is accepted as a +// deprecated alias so older Z.AI call sites keep working. +func EffectiveThinkingEnabled(opts core.ChatOptions) *bool { + if opts.ThinkingEnabled != nil { + return opts.ThinkingEnabled + } + return opts.GLMThinkingEnabled +} + +// NormalizeThinkingOptions copies ThinkingEnabled ↔ GLMThinkingEnabled so both +// fields stay consistent for adapters and older hosts. +func NormalizeThinkingOptions(opts core.ChatOptions) core.ChatOptions { + switch { + case opts.ThinkingEnabled != nil && opts.GLMThinkingEnabled == nil: + opts.GLMThinkingEnabled = opts.ThinkingEnabled + case opts.ThinkingEnabled == nil && opts.GLMThinkingEnabled != nil: + opts.ThinkingEnabled = opts.GLMThinkingEnabled + } + return opts +} diff --git a/config/credential/resolve_test.go b/config/credential/resolve_test.go index 7be8d208..44f34bd6 100644 --- a/config/credential/resolve_test.go +++ b/config/credential/resolve_test.go @@ -59,8 +59,8 @@ func TestResolveCredential_ListsAllProviders(t *testing.T) { t.Fatalf("provider %q should not be inferred without gateway selection", p.ProviderID) } } - if res.Providers[0].ProviderID != "anthropic" { - t.Fatalf("first provider = %q, want anthropic (registry order)", res.Providers[0].ProviderID) + if res.Providers[0].ProviderID != "agnes" { + t.Fatalf("first provider = %q, want agnes (registry order)", res.Providers[0].ProviderID) } } diff --git a/config/deployment_env_sync_test.go b/config/deployment_env_sync_test.go index 477ff6b3..fd260887 100644 --- a/config/deployment_env_sync_test.go +++ b/config/deployment_env_sync_test.go @@ -55,6 +55,26 @@ func TestBuildRoutingPolicyFromDeployments_CanopyWave(t *testing.T) { } } +func TestBuildRoutingPolicyFromDeployments_AgnesAndLongCatSingleEndpoint(t *testing.T) { + t.Parallel() + deployments := map[string]DeploymentConfig{ + "agnes-direct": {}, + "longcat-direct": {}, + "anthropic-direct": {}, + "openrouter": {}, + } + policy := BuildRoutingPolicyFromDeployments(deployments) + for _, provider := range []string{"agnes", "longcat"} { + stages := policy.Providers[provider] + if len(stages) != 1 { + t.Fatalf("%s: want 1 stage, got %+v", provider, stages) + } + if len(stages[0].Deployments) != 1 || stages[0].Deployments[0].DeploymentID != provider+"-direct" { + t.Fatalf("%s: want single %s-direct deployment, got %+v", provider, provider, stages[0].Deployments) + } + } +} + func TestSyncProviderConfigFromCatalog(t *testing.T) { t.Parallel() bootstrap := catalog.BootstrapCatalog() diff --git a/config/profiles.go b/config/profiles.go index 9f78c308..84f7312b 100644 --- a/config/profiles.go +++ b/config/profiles.go @@ -15,6 +15,7 @@ const ( ProviderZAIPayg APIProvider = "zai_payg" ProviderOpenRouter APIProvider = "openrouter" ProviderConcentrate APIProvider = "concentrate" + ProviderAgnes APIProvider = "agnes" ProviderGrok APIProvider = "grok" ProviderGemini APIProvider = "gemini" ProviderBedrock APIProvider = "bedrock" @@ -254,12 +255,14 @@ const ( // OpenAICompatibleRuntimeProfileOrder is the detection order for runtime profiles. var OpenAICompatibleRuntimeProfileOrder = []string{ - "concentrate", "openrouter", "grok", "gemini", "anthropic", "zai_coding", "zai_payg", "canopywave", "deepseek", "poolside", "groq", "clinepass", "openai", "opencodego", "kimi", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", "minimax_token_plan", "minimax_payg", + "concentrate", "agnes", "longcat", "openrouter", "grok", "gemini", "anthropic", "zai_coding", "zai_payg", "canopywave", "deepseek", "poolside", "groq", "clinepass", "openai", "opencodego", "kimi", "xiaomi_mimo_payg", "xiaomi_mimo_token_plan", "minimax_token_plan", "minimax_payg", } // OpenAICompatibleRuntimeProfiles maps profile key to its runtime profile. var OpenAICompatibleRuntimeProfiles = map[string]RuntimeProviderProfile{ "concentrate": ConcentrateRuntimeProfile, + "agnes": OpenAIRuntimeProfile, + "longcat": OpenAIRuntimeProfile, "anthropic": AnthropicRuntimeProfile, "grok": GrokRuntimeProfile, "gemini": GeminiRuntimeProfile, @@ -284,6 +287,7 @@ var OpenAICompatibleRuntimeProfiles = map[string]RuntimeProviderProfile{ var RuntimeProviderProfiles = map[string]RuntimeProviderProfile{ "anthropic": AnthropicRuntimeProfile, "openai": OpenAIRuntimeProfile, + "agnes": OpenAIRuntimeProfile, "grok": GrokRuntimeProfile, "gemini": GeminiRuntimeProfile, "vertex": VertexRuntimeProfile, diff --git a/config/provider_env.go b/config/provider_env.go index 4713dc65..aaaf9bf7 100644 --- a/config/provider_env.go +++ b/config/provider_env.go @@ -70,6 +70,9 @@ type ProviderConfig struct { ConcentrateAPIKey string `json:"concentrate_api_key,omitempty"` ConcentrateBaseURL string `json:"concentrate_base_url,omitempty"` ConcentrateModel string `json:"concentrate_model,omitempty"` + AgnesAPIKey string `json:"agnes_api_key,omitempty"` + AgnesBaseURL string `json:"agnes_base_url,omitempty"` + AgnesModel string `json:"agnes_model,omitempty"` DeepSeekModel string `json:"deepseek_model,omitempty"` ZAIModel string `json:"zai_model,omitempty"` GrokModel string `json:"grok_model,omitempty"` diff --git a/config/provider_secrets.go b/config/provider_secrets.go index 157d43ac..3139d14b 100644 --- a/config/provider_secrets.go +++ b/config/provider_secrets.go @@ -44,6 +44,7 @@ func SanitizeProviderConfigForDisk(cfg ProviderConfig) ProviderConfig { cfg.PoolsideAPIKey = "" cfg.GroqAPIKey = "" cfg.ClinePassAPIKey = "" + cfg.AgnesAPIKey = "" if cfg.Deployments != nil { deployments := make(map[string]DeploymentConfig, len(cfg.Deployments)) for id, deployment := range cfg.Deployments { @@ -91,6 +92,7 @@ func LegacyProviderSecretsStrict(cfg ProviderConfig) (map[string]string, error) put("POOLSIDE_API_KEY", cfg.PoolsideAPIKey) put("GROQ_API_KEY", cfg.GroqAPIKey) put("CLINE_API_KEY", cfg.ClinePassAPIKey) + put("AGNES_API_KEY", cfg.AgnesAPIKey) deploymentIDs := make([]string, 0, len(cfg.Deployments)) for id := range cfg.Deployments { @@ -132,11 +134,14 @@ func legacyDeploymentCredentialEnv(deploymentID string) string { "grok-direct": "XAI_API_KEY", "gemini-direct": "GEMINI_API_KEY", "openrouter": "OPENROUTER_API_KEY", + "concentrate-payg": "CONCENTRATE_API_KEY", "canopywave": "CANOPYWAVE_API_KEY", "deepseek-direct": "DEEPSEEK_API_KEY", "poolside": "POOLSIDE_API_KEY", "groq-direct": "GROQ_API_KEY", "clinepass": "CLINE_API_KEY", + "agnes-direct": "AGNES_API_KEY", + "longcat-direct": "LONGCAT_API_KEY", "zai_payg-direct": "ZAI_API_KEY", "zai_coding-direct": "ZAI_CODING_API_KEY", "opencodego": "OPENCODEGO_API_KEY", @@ -156,7 +161,7 @@ func providerConfigSecrets(cfg ProviderConfig) []string { cfg.OpenRouterAPIKey, cfg.GeminiAPIKey, cfg.OpenCodeGoAPIKey, cfg.MoonshotAPIKey, cfg.XiaomiMimoPaygAPIKey, cfg.XiaomiMimoTokenPlanAPIKey, cfg.MiniMaxTokenPlanAPIKey, cfg.MiniMaxPaygAPIKey, - cfg.PoolsideAPIKey, cfg.GroqAPIKey, cfg.ClinePassAPIKey, + cfg.PoolsideAPIKey, cfg.GroqAPIKey, cfg.ClinePassAPIKey, cfg.AgnesAPIKey, } } diff --git a/config/provider_secrets_test.go b/config/provider_secrets_test.go index c6e4ca99..d79b063c 100644 --- a/config/provider_secrets_test.go +++ b/config/provider_secrets_test.go @@ -52,6 +52,18 @@ func TestLegacyProviderSecretsStrictRejectsUnmappedDeploymentFields(t *testing.T } } +func TestLegacyProviderSecretsStrictMapsLongCatDirect(t *testing.T) { + secrets, err := LegacyProviderSecretsStrict(ProviderConfig{Deployments: map[string]DeploymentConfig{ + "longcat-direct": {APIKey: "lc-direct-secret-1234567890"}, + }}) + if err != nil { + t.Fatal(err) + } + if secrets["LONGCAT_API_KEY"] != "lc-direct-secret-1234567890" { + t.Fatalf("LONGCAT_API_KEY = %q", secrets["LONGCAT_API_KEY"]) + } +} + func TestLegacyProviderSecretsStrictMapsBedrockCompatibilityFields(t *testing.T) { secrets, err := LegacyProviderSecretsStrict(ProviderConfig{Deployments: map[string]DeploymentConfig{ "anthropic-bedrock": {APIKey: "AKIALEGACY123456789", Token: "legacy-secret-1234567890"}, diff --git a/config/providers.go b/config/providers.go index 302b3d42..3c94714e 100644 --- a/config/providers.go +++ b/config/providers.go @@ -37,7 +37,6 @@ const ( DefaultGroqOpenAIBaseURL = "https://api.groq.com/openai/v1" DefaultPoolsideOpenAIBaseURL = "https://inference.poolside.ai/v1" DefaultClinePassOpenAIBaseURL = "https://api.cline.bot/api/v1" // #nosec G101 -- public API base URL, not a secret value - DefaultMiniMaxAnthropicBaseURL = "https://api.minimax.io/anthropic" ) // ProviderTransport is the transport type for provider requests. diff --git a/config/routing_build.go b/config/routing_build.go index a30a9557..dfb6cc78 100644 --- a/config/routing_build.go +++ b/config/routing_build.go @@ -19,6 +19,12 @@ func BuildRoutingPolicyFromDeployments(deployments map[string]DeploymentConfig) if stages := openAIProviderStages(deployments); len(stages) > 0 { policy.Providers["openai"] = stages } + if stages := agnesProviderStages(deployments); len(stages) > 0 { + policy.Providers["agnes"] = stages + } + if stages := longcatProviderStages(deployments); len(stages) > 0 { + policy.Providers["longcat"] = stages + } if stages := anthropicProviderStages(deployments); len(stages) > 0 { policy.Providers["anthropic"] = stages } @@ -92,6 +98,23 @@ func anthropicProviderStages(deployments map[string]DeploymentConfig) []RoutingS return stages } +func agnesProviderStages(deployments map[string]DeploymentConfig) []RoutingStage { + if _, ok := deployments["agnes-direct"]; !ok { + return nil + } + // Agnes is OpenAI-compatible only: one deployment, one protocol. + return singleDeploymentStages("agnes-direct", 1) +} + +func longcatProviderStages(deployments map[string]DeploymentConfig) []RoutingStage { + if _, ok := deployments["longcat-direct"]; !ok { + return nil + } + // Single OpenAI-compatible endpoint only (longcat-direct). + // Official LongCat also documents /anthropic; hawk does not require it when OpenAI works. + return singleDeploymentStages("longcat-direct", 1) +} + func googleProviderStages(deployments map[string]DeploymentConfig) []RoutingStage { if _, ok := deployments["gemini-direct"]; ok { stages := singleDeploymentStages("gemini-direct", 1) @@ -130,6 +153,10 @@ func deploymentOwnerProviderID(deploymentID string) string { return "google" case "grok-direct": return "xai" + case "longcat-direct": + return "longcat" + case "agnes-direct": + return "agnes" case "openrouter": return "openrouter" case "canopywave": diff --git a/config/xiaomi_profile.go b/config/xiaomi_profile.go index 531b4a84..2d354d9a 100644 --- a/config/xiaomi_profile.go +++ b/config/xiaomi_profile.go @@ -43,16 +43,6 @@ func ResolveXiaomiOpenAIBase(providerID string, cfg *ProviderConfig) (string, er return xiaomi.ResolveOpenAIBasePreferRegion(billing, region, override) } -// ResolveXiaomiAnthropicBase resolves the Anthropic-compat base for a MiMo gateway id. -func ResolveXiaomiAnthropicBase(providerID string, cfg *ProviderConfig) (string, error) { - billing, ok := xiaomi.BillingForProvider(providerID) - if !ok { - return "", nil - } - region := XiaomiTokenPlanRegionFromConfig(cfg) - return xiaomi.ResolveAnthropicBase(billing, region) -} - // IsXiaomiMimoProvider reports whether id is a MiMo setup gateway (payg or token plan). func IsXiaomiMimoProvider(providerID string) bool { _, ok := xiaomi.BillingForProvider(providerID) diff --git a/config/zai_profile.go b/config/zai_profile.go index b908d4c9..c0e2c556 100644 --- a/config/zai_profile.go +++ b/config/zai_profile.go @@ -28,20 +28,6 @@ func ResolveZAIOpenAIBase(providerID string, cfg *ProviderConfig) (string, error return zai.ResolveOpenAIBase(plan, region, override) } -// ResolveZAIAnthropicBase resolves the Anthropic-compat base for Z.AI. -// Prefers coding region if set, falls back to general region. -func ResolveZAIAnthropicBase(cfg *ProviderConfig) string { - if cfg == nil { - return zai.ResolveAnthropicBase(zai.RegionInternational) - } - regionStr := cfg.ZAICodingRegion - if regionStr == "" { - regionStr = cfg.ZAIRegion - } - region, _ := zai.NormalizeRegion(regionStr) - return zai.ResolveAnthropicBase(region) -} - // IsZAIProvider reports whether id is a Z.AI setup gateway (payg or coding). func IsZAIProvider(providerID string) bool { _, ok := zai.PlanForProvider(providerID) diff --git a/engine/control_plane.go b/engine/control_plane.go index 62870ddc..cbb2ee6f 100644 --- a/engine/control_plane.go +++ b/engine/control_plane.go @@ -65,6 +65,13 @@ func (e *Engine) CredentialProviders(context.Context) []CredentialProvider { return out } +// RegisteredProviderCount reports the number of first-class providers exposed +// by Eyrie's canonical registry. Hosts can use this for informational UI copy +// without importing lower-level catalog packages. +func RegisteredProviderCount() int { + return len(registry.All()) +} + // GatewayDefinitions returns pure registry/custom metadata in setup UI order. // It does not read credentials, provider state, or the model catalog. func (e *Engine) GatewayDefinitions() []Gateway { diff --git a/engine/convert.go b/engine/convert.go index d6a1518c..80378547 100644 --- a/engine/convert.go +++ b/engine/convert.go @@ -25,7 +25,14 @@ func toClientOptions(req GenerateRequest, route Route, stream bool) client.ChatO opts.ThinkingBudgetTokens = advanced.ThinkingBudgetTokens opts.ThinkingMode = advanced.ThinkingMode opts.ThinkingDisplay = advanced.ThinkingDisplay + opts.ThinkingEnabled = advanced.ThinkingEnabled opts.GLMThinkingEnabled = advanced.GLMThinkingEnabled + if opts.ThinkingEnabled == nil && opts.GLMThinkingEnabled != nil { + opts.ThinkingEnabled = opts.GLMThinkingEnabled + } + if opts.GLMThinkingEnabled == nil && opts.ThinkingEnabled != nil { + opts.GLMThinkingEnabled = opts.ThinkingEnabled + } opts.VirtualKeyID = advanced.VirtualKeyID opts.KimiContextCacheID = advanced.KimiContextCacheID opts.KimiCacheResetTTL = advanced.KimiCacheResetTTL diff --git a/engine/engine.go b/engine/engine.go index 19346ba3..87c2765e 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -552,7 +552,13 @@ func offeringSupports(compiled *catalog.CompiledCatalog, modelID string, req Req for _, offering := range offerings { caps := offering.Capabilities if req.Tools && caps.FunctionCalling != catalog.CapabilitySupported { - continue + // Treat unknown capability as supported. Providers that don't + // report function-calling capability (e.g. free tiers, smaller + // gateways) may still support tools. Better to try and fail than + // block models from being used. Only skip on explicit unsupported. + if caps.FunctionCalling == catalog.CapabilityUnsupported { + continue + } } if req.Vision && caps.ImageInput != catalog.CapabilitySupported { continue diff --git a/engine/engine_test.go b/engine/engine_test.go index 525ce758..8429db11 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -212,20 +212,42 @@ func TestSelectCompatibleModelUsesCapabilitiesAndIntent(t *testing.T) { }, } + // vendor/text has unknown capabilities — treated as supporting tools + // and selected because it's the cheapest option model, provider := selectCompatibleModel(compiled, SelectionRequest{ Requirements: Requirements{Tools: true, MinimumContext: 100_000}, Preference: Preference{Intent: llm.IntentEconomical}, }) - if model != "vendor/cheap" || provider != "vendor" { - t.Fatalf("selected %q via %q, want vendor/cheap via vendor", model, provider) + if model != "vendor/text" || provider != "vendor" { + t.Fatalf("selected %q via %q, want vendor/text via vendor", model, provider) } model, _ = selectCompatibleModel(compiled, SelectionRequest{ Requirements: Requirements{Tools: true, MinimumContext: 150_000}, Preference: Preference{Intent: llm.IntentReasoning}, }) - if model != "vendor/rich" { - t.Fatalf("selected %q, want vendor/rich", model) + // vendor/text selected: unknown capabilities treated as supported, largest context + if model != "vendor/text" { + t.Fatalf("selected %q, want vendor/text", model) + } +} + +func TestSelectCompatibleModel_ConcentrateLegacyCacheAssumesTools(t *testing.T) { + compiled := &catalog.CompiledCatalog{ + ModelsByID: map[string]catalog.Model{ + "concentrate/deepseek-v4-pro": {ID: "concentrate/deepseek-v4-pro", ProviderID: "concentrate", ContextWindow: 1_040_000}, + }, + OfferingsByCanonicalModel: map[string][]catalog.ModelOffering{ + "concentrate/deepseek-v4-pro": {{CanonicalModelID: "concentrate/deepseek-v4-pro", DeploymentID: "concentrate-payg"}}, + }, + } + + model, provider := selectCompatibleModel(compiled, SelectionRequest{ + Requirements: Requirements{Tools: true}, + Preference: Preference{PreferredProvider: "concentrate"}, + }) + if model != "concentrate/deepseek-v4-pro" || provider != "concentrate" { + t.Fatalf("selected %q via %q, want Concentrate model with tools", model, provider) } } diff --git a/engine/host_facade_contract_test.go b/engine/host_facade_contract_test.go index 11034d29..a15a7115 100644 --- a/engine/host_facade_contract_test.go +++ b/engine/host_facade_contract_test.go @@ -7,9 +7,16 @@ import ( "testing" "github.com/GrayCodeAI/eyrie/catalog" + "github.com/GrayCodeAI/eyrie/catalog/registry" "github.com/GrayCodeAI/eyrie/credentials" ) +func TestRegisteredProviderCountMatchesCanonicalRegistry(t *testing.T) { + if got, want := RegisteredProviderCount(), len(registry.All()); got != want { + t.Fatalf("registered provider count = %d, want %d", got, want) + } +} + func TestGatewayDefinitionsArePureMetadataWithSeparateRanks(t *testing.T) { store := &countingStore{inner: &credentials.MapStore{}} eng, err := New(Options{SecretStore: store, StateDir: t.TempDir(), CustomGateways: []CustomGateway{}}) diff --git a/go.mod b/go.mod index a6f0aead..f69aa181 100644 --- a/go.mod +++ b/go.mod @@ -9,29 +9,32 @@ require ( github.com/zalando/go-keyring v0.2.8 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 - google.golang.org/grpc v1.81.1 + google.golang.org/grpc v1.82.0 modernc.org/sqlite v1.51.0 ) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect - github.com/dlclark/regexp2/v2 v2.1.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/stretchr/objx v0.5.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/tools v0.44.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.72.5 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 1e8d6e26..ea04922d 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,10 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2/v2 v2.1.0 h1:jHXRmHRZGbuQzDZjMlCAXOvQb75iv3HyLDzXGj5H1AY= -github.com/dlclark/regexp2/v2 v2.1.0/go.mod h1:Bz5TMy5d8fPK0ximH0Yi9KvsRHNnvXqUx9XG6a4wB+I= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -21,8 +21,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -31,8 +31,8 @@ github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= @@ -49,30 +49,30 @@ go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/router/retry.go b/router/retry.go index 7f5e7694..999e54ae 100644 --- a/router/retry.go +++ b/router/retry.go @@ -46,6 +46,8 @@ func ShouldTryNextDeployment(err error) bool { for _, pattern := range []string{ "requires more credits", "can only afford", "insufficient credits", "insufficient balance", "payment required", "out of credits", "402", + "insufficient_user_quota", "insufficient_quota", + "pre-deduct", "pre-deduction", "预扣费", } { if strings.Contains(low, pattern) { return true diff --git a/setup/deployment.go b/setup/deployment.go index a6156d11..13fc2149 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -11,6 +11,7 @@ import ( "github.com/GrayCodeAI/eyrie/catalog/xiaomi" "github.com/GrayCodeAI/eyrie/catalog/zai" "github.com/GrayCodeAI/eyrie/client" + "github.com/GrayCodeAI/eyrie/client/adapters" "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/credentials" "github.com/GrayCodeAI/eyrie/router" @@ -273,12 +274,12 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultOpenRouterOpenAIBaseURL), &client.OpenRouterCompat), true - case "concentrate-direct": + case "concentrate-payg": apiKey := FirstNonEmpty(deployment.APIKey, lookup("CONCENTRATE_API_KEY")) if apiKey == "" { return nil, false } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultConcentrateOpenAIBaseURL), &client.OpenAICompat), true + return adapters.NewConcentrateResponsesClient(apiKey, FirstNonEmpty(deployment.BaseURL, config.DefaultConcentrateOpenAIBaseURL)), true case "canopywave": apiKey := FirstNonEmpty(deployment.APIKey, lookup("CANOPYWAVE_API_KEY")) if apiKey == "" { @@ -291,8 +292,7 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c return nil, false } openBase := FirstNonEmpty(deployment.BaseURL, "https://api.deepseek.com/v1") - anthropicBase := "https://api.deepseek.com/anthropic" - return client.NewDeepSeekClient(apiKey, openBase, anthropicBase, &client.DeepSeekCompat), true + return client.NewDeepSeekClient(apiKey, openBase, &client.DeepSeekCompat), true case "poolside": apiKey := FirstNonEmpty(deployment.APIKey, lookup("POOLSIDE_API_KEY")) if apiKey == "" { @@ -339,13 +339,25 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c if apiKey == "" { return nil, false } - return newMiniMaxDualProtocolClient(apiKey, deployment.BaseURL), true + return newMiniMaxOpenAIClient(apiKey, deployment.BaseURL), true + case "agnes-direct": + apiKey := FirstNonEmpty(deployment.APIKey, lookup("AGNES_API_KEY")) + if apiKey == "" { + return nil, false + } + return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, "https://apihub.agnes-ai.com/v1"), &client.AgnesCompat), true + case "longcat-direct": + apiKey := FirstNonEmpty(deployment.APIKey, lookup("LONGCAT_API_KEY")) + if apiKey == "" { + return nil, false + } + return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, "https://api.longcat.chat/openai/v1"), &client.LongCatCompat), true case "minimax_payg-direct": apiKey := FirstNonEmpty(deployment.APIKey, lookup("MINIMAX_PAYG_API_KEY")) if apiKey == "" { return nil, false } - return newMiniMaxDualProtocolClient(apiKey, deployment.BaseURL), true + return newMiniMaxOpenAIClient(apiKey, deployment.BaseURL), true default: return nil, false } @@ -366,13 +378,11 @@ func newMiMoDeploymentClient(deployment config.DeploymentConfig, providerID, env openBase = override } } - anthropicBase, _ := config.ResolveXiaomiAnthropicBase(providerID, cfg) - return client.NewMiMoClient(apiKey, openBase, anthropicBase, &client.XiaomiCompat, providerID), true + return client.NewMiMoClient(apiKey, openBase, &client.XiaomiCompat, providerID), true } -// newZAIDeploymentClient constructs a dual-protocol (OpenAI + Anthropic) Z.AI client -// for either the general or Coding Plan gateway, resolving the correct bases -// for the plan + region (international or china) per official docs. +// newZAIDeploymentClient constructs an OpenAI-compatible Z.AI client for the +// general or Coding Plan gateway. func newZAIDeploymentClient(deployment config.DeploymentConfig, providerID, envKey string, lookup func(...string) string, cfg *config.ProviderConfig) (client.Provider, bool) { apiKey := FirstNonEmpty(deployment.APIKey, lookup(envKey)) if apiKey == "" { @@ -390,9 +400,7 @@ func newZAIDeploymentClient(deployment config.DeploymentConfig, providerID, envK } } - anthropicBase := resolveZAIAnthropicBaseForDeployment(plan, cfg) - - return client.NewZAIClient(apiKey, openBase, anthropicBase, &client.ZAICompat, providerID), true + return client.NewZAIClient(apiKey, openBase, &client.ZAICompat, providerID), true } func resolveZAIOpenAIBaseForDeployment(plan zai.Plan, providerID string, cfg *config.ProviderConfig, override string) (string, error) { @@ -408,19 +416,6 @@ func resolveZAIOpenAIBaseForDeployment(plan zai.Plan, providerID string, cfg *co return zai.ResolveOpenAIBase(plan, region, override) } -func resolveZAIAnthropicBaseForDeployment(plan zai.Plan, cfg *config.ProviderConfig) string { - // region from general or coding, prefer coding if set - regionStr := "" - if cfg != nil { - regionStr = cfg.ZAICodingRegion - if regionStr == "" { - regionStr = cfg.ZAIRegion - } - } - region, _ := zai.NormalizeRegion(regionStr) - return zai.ResolveAnthropicBase(region) -} - // DefaultDeploymentForProvider maps a logical provider name to a deployment ID. func DefaultDeploymentForProvider(provider string) string { switch provider { @@ -441,7 +436,7 @@ func DefaultDeploymentForProvider(provider string) string { case config.ProviderOpenRouter: return "openrouter" case config.ProviderConcentrate: - return "concentrate-direct" + return "concentrate-payg" case config.ProviderCanopyWave: return "canopywave" case config.ProviderPoolside: @@ -573,19 +568,10 @@ func FirstNonEmpty(values ...string) string { return "" } -// newMiniMaxDualProtocolClient creates a FallbackProvider that tries OpenAI-compatible -// endpoint first, then falls back to Anthropic-compatible endpoint. Both use the same API key. -func newMiniMaxDualProtocolClient(apiKey, baseURL string) client.Provider { +// newMiniMaxOpenAIClient uses the official OpenAI-compatible MiniMax surface only. +func newMiniMaxOpenAIClient(apiKey, baseURL string) client.Provider { openaiBase := FirstNonEmpty(baseURL, config.DefaultMiniMaxOpenAIBaseURL) - anthropicBase := config.DefaultMiniMaxAnthropicBaseURL - openaiClient := client.NewOpenAIClient(apiKey, openaiBase, &client.OpenAICompat) - anthropicClient := client.NewAnthropicClient(apiKey, anthropicBase) - fp, err := client.NewFallbackProvider(openaiClient, anthropicClient) - if err != nil { - // Cannot happen with two providers; return the primary as fallback. - return openaiClient - } - return fp + return client.NewOpenAIClient(apiKey, openaiBase, &client.MiniMaxCompat) } // CloneStringMap returns a shallow copy of m. diff --git a/setup/deployment_test.go b/setup/deployment_test.go index 0617c7db..5a1b7be0 100644 --- a/setup/deployment_test.go +++ b/setup/deployment_test.go @@ -6,6 +6,7 @@ import ( "github.com/GrayCodeAI/eyrie/catalog" "github.com/GrayCodeAI/eyrie/client" + "github.com/GrayCodeAI/eyrie/client/adapters" "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/credentials" ) @@ -329,7 +330,7 @@ func TestDefaultDeploymentForProvider(t *testing.T) { {config.ProviderGrok, "grok-direct"}, {config.ProviderGemini, "gemini-direct"}, {config.ProviderOpenRouter, "openrouter"}, - {config.ProviderConcentrate, "concentrate-direct"}, + {config.ProviderConcentrate, "concentrate-payg"}, {config.ProviderCanopyWave, "canopywave"}, {config.ProviderDeepSeek, "deepseek-direct"}, {config.ProviderZAIPayg, "zai_payg-direct"}, @@ -507,24 +508,27 @@ func TestProviderForDeployment_OpenRouter(t *testing.T) { } } -func TestProviderForDeployment_ConcentrateDirect(t *testing.T) { - p, ok := ProviderForDeployment("concentrate-direct", config.DeploymentConfig{APIKey: "test-key"}) +func TestProviderForDeployment_ConcentratePayAsYouGo(t *testing.T) { + p, ok := ProviderForDeployment("concentrate-payg", config.DeploymentConfig{APIKey: "test-key"}) if !ok { - t.Fatal("expected concentrate-direct to be configured") + t.Fatal("expected concentrate-payg to be configured") } - if p.Name() != "openai" { - t.Fatalf("provider name = %q, want openai", p.Name()) + if _, ok := p.(*adapters.ConcentrateResponsesClient); !ok { + t.Fatalf("provider type = %T, want Responses API adapter", p) + } + if p.Name() != "concentrate" { + t.Fatalf("provider name = %q, want concentrate", p.Name()) } } -func TestProviderForDeployment_ConcentrateDirectRequiresKey(t *testing.T) { +func TestProviderForDeployment_ConcentratePayAsYouGoRequiresKey(t *testing.T) { store := &credentials.MapStore{} credentials.SetDefaultStore(store) t.Cleanup(func() { credentials.SetDefaultStore(nil) }) t.Setenv("CONCENTRATE_API_KEY", "") - if _, ok := ProviderForDeployment("concentrate-direct", config.DeploymentConfig{}); ok { - t.Fatal("expected concentrate-direct to be unavailable without key") + if _, ok := ProviderForDeployment("concentrate-payg", config.DeploymentConfig{}); ok { + t.Fatal("expected concentrate-payg to be unavailable without key") } }