From 6631e6fd2d98d298e9c2090600436e08e5bce242 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Wed, 29 Jul 2026 22:25:53 +0530 Subject: [PATCH 01/18] feat: strict mode compliance and tool schema normalization for Concentrate - Add Strict field to responsesTool struct, set strict: true on all tools - Add normalizeToolParams to inject additionalProperties: false for object schemas - Restructure responsesTool to flat format (name/description/parameters at top level) - Add Store field to responsesRequest - Expand response types with Status, Error, IncompleteDetails - Add bufio import for SSE reader Per Concentrate API docs: strict mode requires additionalProperties: false. --- AGENTS.md | 5 +- catalog/concentrate/concentrate.go | 88 +--- catalog/concentrate/concentrate_test.go | 99 ----- catalog/default_catalog_test.go | 22 +- catalog/discover/discover.go | 1 + catalog/discover/provider_refresh.go | 19 +- catalog/discover/provider_refresh_test.go | 47 ++ catalog/live/concentrate_test.go | 33 ++ catalog/live/fetchers_providers.go | 20 +- catalog/live_enrich.go | 8 +- catalog/live_enrich_test.go | 32 ++ catalog/registry/provider_spec_test.go | 19 +- catalog/registry/providers.go | 5 +- catalog/registry/spec.go | 1 + catalog/topology.go | 75 ++++ catalog/topology_test.go | 76 ++++ catalog/v1_defaults.go | 5 +- client/adapters/concentrate.go | 59 --- client/adapters/concentrate_responses.go | 405 ++++++++++++++---- client/adapters/concentrate_responses_test.go | 289 ++++++++++--- client/adapters/concentrate_test.go | 149 ------- config/provider_secrets.go | 1 + engine/control_plane.go | 7 + engine/engine.go | 9 +- engine/engine_test.go | 19 + engine/host_facade_contract_test.go | 7 + setup/deployment.go | 7 +- setup/deployment_test.go | 22 +- 28 files changed, 954 insertions(+), 575 deletions(-) delete mode 100644 catalog/concentrate/concentrate_test.go create mode 100644 catalog/live/concentrate_test.go create mode 100644 catalog/topology.go create mode 100644 catalog/topology_test.go delete mode 100644 client/adapters/concentrate.go delete mode 100644 client/adapters/concentrate_test.go 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/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_providers.go b/catalog/live/fetchers_providers.go index c30aa943..8f9a9278 100644 --- a/catalog/live/fetchers_providers.go +++ b/catalog/live/fetchers_providers.go @@ -590,7 +590,7 @@ func FetchPoolside(env map[string]string) ([]Entry, error) { ) } -// FetchConcentrate lists models from the Concentrate AI OpenAI-compatible API. +// 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. @@ -687,12 +687,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 +765,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_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/registry/provider_spec_test.go b/catalog/registry/provider_spec_test.go index a8bb64a4..d003da9b 100644 --- a/catalog/registry/provider_spec_test.go +++ b/catalog/registry/provider_spec_test.go @@ -46,6 +46,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 +118,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..b07be9e8 100644 --- a/catalog/registry/providers.go +++ b/catalog/registry/providers.go @@ -165,12 +165,13 @@ 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: 16, 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", }, // ── Niche ───────────────────────────────────────────────────────── 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_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/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..d005b3d5 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,12 @@ 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"` } type responsesReasoning struct { @@ -70,27 +72,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 +118,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 +169,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 +192,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 +230,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 +263,150 @@ 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") + } + 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) } - t.Function.Name = tool.Name - t.Function.Description = tool.Description - t.Function.Parameters = tool.Parameters - req.Tools = append(req.Tools, t) + format["name"] = "hawk_response" + format["schema"] = schema } + req.Text = &responsesTextConfig{Format: format} } - // 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 params + } + // 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 != "" || 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) } - if msg.Content != "" { - item["content"] = msg.Content + + 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 +425,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 +440,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 +449,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 +503,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 + } + } + 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 + } } - events <- core.EyrieStreamEvent{Type: "done", StopReason: "stop"} + 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/config/provider_secrets.go b/config/provider_secrets.go index 157d43ac..d8a67330 100644 --- a/config/provider_secrets.go +++ b/config/provider_secrets.go @@ -132,6 +132,7 @@ 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", 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/engine.go b/engine/engine.go index 19346ba3..f936fbde 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -552,7 +552,14 @@ func offeringSupports(compiled *catalog.CompiledCatalog, modelID string, req Req for _, offering := range offerings { caps := offering.Capabilities if req.Tools && caps.FunctionCalling != catalog.CapabilitySupported { - continue + // Older Concentrate caches predate the provider-level tool + // capability in its model-list response. Its Responses API + // model-details endpoint advertises function calling, so treat + // an unknown value as supported while preserving explicit + // unsupported declarations. + if caps.FunctionCalling == catalog.CapabilityUnsupported || offering.DeploymentID != "concentrate-payg" { + continue + } } if req.Vision && caps.ImageInput != catalog.CapabilitySupported { continue diff --git a/engine/engine_test.go b/engine/engine_test.go index 525ce758..94ce54a5 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -229,6 +229,25 @@ func TestSelectCompatibleModelUsesCapabilitiesAndIntent(t *testing.T) { } } +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) + } +} + func TestTypedErrorUnwrapAndCode(t *testing.T) { cause := errors.New("provider down") err := classify("stream", Route{Provider: "mock", Model: "m"}, cause) 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/setup/deployment.go b/setup/deployment.go index a6156d11..068b777c 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 == "" { @@ -441,7 +442,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: 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") } } From 549afd55388fe84e864c4a1955ff4789249b0d07 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 04:16:10 +0530 Subject: [PATCH 02/18] fix: fetch Concentrate pricing by default instead of opt-in Previously required CONCENTRATE_FETCH_PRICING=true env var, which meant most users saw "free" for all models. Now pricing is fetched by default and cached for 24 hours. Falls back gracefully to no pricing if API key is unavailable. --- catalog/live/fetchers_providers.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/catalog/live/fetchers_providers.go b/catalog/live/fetchers_providers.go index 8f9a9278..acb307cf 100644 --- a/catalog/live/fetchers_providers.go +++ b/catalog/live/fetchers_providers.go @@ -592,19 +592,21 @@ func FetchPoolside(env map[string]string) ([]Entry, error) { // 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"), "/") From 961dc2d5e2d4a4cc22bfad1433fe18df6078ca21 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 04:35:03 +0530 Subject: [PATCH 03/18] feat: implement Concentrate Responses API extended parameters Map all Concentrate API fields from ChatOptions to responsesRequest: - include, routing, max_tool_calls, context_management - safety_identifier, background, truncation, conversation Per: https://concentrate.ai/docs/api-reference/endpoint/request-parameters --- client/adapters/concentrate_responses.go | 87 +++++++++++++++++++----- 1 file changed, 71 insertions(+), 16 deletions(-) diff --git a/client/adapters/concentrate_responses.go b/client/adapters/concentrate_responses.go index d005b3d5..98ea8c0d 100644 --- a/client/adapters/concentrate_responses.go +++ b/client/adapters/concentrate_responses.go @@ -48,22 +48,41 @@ func (c *ConcentrateResponsesClient) Name() string { return "concentrate" } // responsesRequest is the request body for the Concentrate Responses API. type responsesRequest struct { - Input interface{} `json:"input"` - Model string `json:"model"` - Instructions string `json:"instructions,omitempty"` - MaxOutputTokens int `json:"max_output_tokens,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - Stream bool `json:"stream,omitempty"` - Reasoning *responsesReasoning `json:"reasoning,omitempty"` - Tools []responsesTool `json:"tools,omitempty"` - ToolChoice interface{} `json:"tool_choice,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"` + Input interface{} `json:"input"` + Model string `json:"model"` + Instructions string `json:"instructions,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Stream bool `json:"stream,omitempty"` + Reasoning *responsesReasoning `json:"reasoning,omitempty"` + Tools []responsesTool `json:"tools,omitempty"` + ToolChoice interface{} `json:"tool_choice,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"` +} + +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 { @@ -295,6 +314,42 @@ func (c *ConcentrateResponsesClient) buildRequest(messages []core.EyrieMessage, req.Text = &responsesTextConfig{Format: format} } + // Map extended Responses API parameters + if opts.PreviousResponseID != "" { + req.PreviousResponseID = opts.PreviousResponseID + } + if len(opts.Include) > 0 { + req.Include = opts.Include + } + if opts.Routing != nil { + req.Routing = &routingConfig{ + Model: opts.Routing.Model, + Strategy: opts.Routing.Strategy, + Metric: opts.Routing.Metric, + } + } + if opts.MaxToolCalls != nil { + req.MaxToolCalls = opts.MaxToolCalls + } + if len(opts.ContextManagement) > 0 { + req.ContextManagement = make([]contextManagement, 0, len(opts.ContextManagement)) + for _, cm := range opts.ContextManagement { + req.ContextManagement = append(req.ContextManagement, contextManagement{Type: cm.Type}) + } + } + if opts.SafetyIdentifier != nil { + req.SafetyIdentifier = opts.SafetyIdentifier + } + if opts.Background != nil { + req.Background = opts.Background + } + if opts.Truncation != nil { + req.Truncation = opts.Truncation + } + if opts.Conversation != nil { + req.Conversation = opts.Conversation + } + return req, nil } From a51be0ada557ef8189df5856342c1275183a855e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 04:56:21 +0530 Subject: [PATCH 04/18] fix: restore go.mod after workspace testing Revert manual go.mod edits; workspace handles replacement. --- client/adapters/concentrate_responses.go | 44 +++++++++++++++---- go.mod | 17 ++++--- go.sum | 56 ++++++++++++------------ 3 files changed, 74 insertions(+), 43 deletions(-) diff --git a/client/adapters/concentrate_responses.go b/client/adapters/concentrate_responses.go index 98ea8c0d..70cba988 100644 --- a/client/adapters/concentrate_responses.go +++ b/client/adapters/concentrate_responses.go @@ -65,14 +65,27 @@ type responsesRequest struct { 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"` + 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 { @@ -349,6 +362,21 @@ func (c *ConcentrateResponsesClient) buildRequest(messages []core.EyrieMessage, if opts.Conversation != nil { req.Conversation = opts.Conversation } + if opts.ServiceTier != "" { + req.ServiceTier = opts.ServiceTier + } + if opts.PromptCacheOptions != nil { + req.PromptCacheOptions = &promptCacheOptions{ + Mode: opts.PromptCacheOptions.Mode, + TTL: opts.PromptCacheOptions.TTL, + } + } + if opts.CacheControl != nil { + req.CacheControl = &cacheControl{ + Type: opts.CacheControl.Type, + TTL: opts.CacheControl.TTL, + } + } return req, nil } 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= From 00e94dd2fe5d4691202d6cc0759f2e38dbed589a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 05:19:46 +0530 Subject: [PATCH 05/18] feat: add Agnes AI as free OpenAI-compatible provider - Register Agnes in provider registry (ChatPreference: 25, free tier) - Add FetchAgnes live fetcher calling /v1/models - Add AGNES_API_KEY credential mapping - Add Agnes fields to ProviderConfig - Update tests for 24 providers (was 23) - Agnes is free with RPM limits, no per-token pricing Base URL: https://apihub.agnes-ai.com/v1 --- catalog/live/agnes_test.go | 100 +++++++++++++++++++++++++ catalog/live/fetchers.go | 1 + catalog/live/fetchers_providers.go | 19 +++++ catalog/provider_live_parity_test.go | 4 +- catalog/registry/provider_spec_test.go | 8 +- catalog/registry/providers.go | 8 ++ config/provider_env.go | 3 + config/provider_secrets.go | 5 +- 8 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 catalog/live/agnes_test.go diff --git a/catalog/live/agnes_test.go b/catalog/live/agnes_test.go new file mode 100644 index 00000000..c8aa506e --- /dev/null +++ b/catalog/live/agnes_test.go @@ -0,0 +1,100 @@ +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) + } + + // Agnes is free — prices should be 0 + for _, e := range entries { + if e.InputPricePer1M != 0 || e.OutputPricePer1M != 0 { + t.Errorf("expected zero pricing for free Agnes model %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/fetchers.go b/catalog/live/fetchers.go index 95e1e0f0..6eeaba95 100644 --- a/catalog/live/fetchers.go +++ b/catalog/live/fetchers.go @@ -71,6 +71,7 @@ var Registry = map[string]FetchFunc{ "zai_payg": FetchZAI, "zai_coding": FetchZAICoding, "concentrate": FetchConcentrate, + "agnes": FetchAgnes, "canopywave": FetchCanopyWave, "opencodego": FetchOpenCodeGo, "kimi": FetchKimi, diff --git a/catalog/live/fetchers_providers.go b/catalog/live/fetchers_providers.go index acb307cf..10c5ddd6 100644 --- a/catalog/live/fetchers_providers.go +++ b/catalog/live/fetchers_providers.go @@ -590,6 +590,25 @@ func FetchPoolside(env map[string]string) ([]Entry, error) { ) } +// FetchAgnes lists models from the Agnes AI OpenAI-compatible API. +// Agnes models are free with RPM limits. No per-token pricing. +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 + } + // Agnes is free tier — all models have zero per-token pricing + for i := range entries { + entries[i].InputPricePer1M = 0 + entries[i].OutputPricePer1M = 0 + } + return entries, nil +} + // FetchConcentrate lists models from the public Concentrate AI model catalog. // Concentrate returns a rich format with max_input_tokens, max_tokens, and capabilities. // Pricing is fetched from individual model endpoints and cached to disk for 24 hours. diff --git a/catalog/provider_live_parity_test.go b/catalog/provider_live_parity_test.go index 00e4ce50..e46ec7ef 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) != 24 { + t.Fatalf("expected 24 providers, got %d", len(specs)) } for _, spec := range specs { t.Run(spec.ProviderID, func(t *testing.T) { diff --git a/catalog/registry/provider_spec_test.go b/catalog/registry/provider_spec_test.go index d003da9b..ea02cdad 100644 --- a/catalog/registry/provider_spec_test.go +++ b/catalog/registry/provider_spec_test.go @@ -9,8 +9,8 @@ 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 != 24 { + t.Fatalf("expected 24 providers, got %d", n) } } @@ -24,8 +24,8 @@ func TestCredentialRegistry_MatchesAll(t *testing.T) { 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) != 24 { + t.Fatalf("expected 24 live fetcher keys, got %d", len(keys)) } } diff --git a/catalog/registry/providers.go b/catalog/registry/providers.go index b07be9e8..08c16627 100644 --- a/catalog/registry/providers.go +++ b/catalog/registry/providers.go @@ -173,6 +173,14 @@ func providerSpecs() []ProviderSpec { PublicModelCatalog: true, ProtocolID: "openai-responses", AdapterID: "concentrate-responses", RuntimeProfileKey: "concentrate", }, + { + ProviderID: "agnes", DisplayName: "Agnes AI", DeploymentID: "agnes-direct", SortOrder: 17, 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", + }, // ── Niche ───────────────────────────────────────────────────────── { 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 d8a67330..ca6ce8ae 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 { @@ -138,6 +140,7 @@ func legacyDeploymentCredentialEnv(deploymentID string) string { "poolside": "POOLSIDE_API_KEY", "groq-direct": "GROQ_API_KEY", "clinepass": "CLINE_API_KEY", + "agnes-direct": "AGNES_API_KEY", "zai_payg-direct": "ZAI_API_KEY", "zai_coding-direct": "ZAI_CODING_API_KEY", "opencodego": "OPENCODEGO_API_KEY", @@ -157,7 +160,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, } } From ee40aaf865551ebb386e599a0d74c0e6237f3f8f Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 05:27:58 +0530 Subject: [PATCH 06/18] fix: treat unknown capabilities as supported for all providers Previously only Concentrate got special treatment for unknown function-calling capability. Now all providers with unknown capabilities (like Agnes AI free tier) are assumed to support tools. Better to try and fail than block models from being used. Only explicit unsupported blocks a model. Updates test to reflect new behavior: unknown = assumed supported. --- engine/engine.go | 11 +++++------ engine/engine_test.go | 11 +++++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/engine/engine.go b/engine/engine.go index f936fbde..87c2765e 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -552,12 +552,11 @@ func offeringSupports(compiled *catalog.CompiledCatalog, modelID string, req Req for _, offering := range offerings { caps := offering.Capabilities if req.Tools && caps.FunctionCalling != catalog.CapabilitySupported { - // Older Concentrate caches predate the provider-level tool - // capability in its model-list response. Its Responses API - // model-details endpoint advertises function calling, so treat - // an unknown value as supported while preserving explicit - // unsupported declarations. - if caps.FunctionCalling == catalog.CapabilityUnsupported || offering.DeploymentID != "concentrate-payg" { + // 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 } } diff --git a/engine/engine_test.go b/engine/engine_test.go index 94ce54a5..8429db11 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -212,20 +212,23 @@ 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) } } From 8b21d7501fd6b651c4a41c3755afb3c4f39bb103 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 05:34:49 +0530 Subject: [PATCH 07/18] fix: add Agnes deployment routing and runtime profile - Add agnes-direct case in providerForDeployment switch - Add Agnes to OpenAICompatibleRuntimeProfiles and RuntimeProviderProfiles - Add ProviderAgnes constant - Reuses OpenAI adapter (OpenAICompat) for Agnes OpenAI-compatible API - Base URL: https://apihub.agnes-ai.com/v1 Fixes no route configured error when selecting Agnes models. --- config/profiles.go | 5 ++++- setup/deployment.go | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/config/profiles.go b/config/profiles.go index 9f78c308..09ca7e45 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,13 @@ 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", "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, "anthropic": AnthropicRuntimeProfile, "grok": GrokRuntimeProfile, "gemini": GeminiRuntimeProfile, @@ -284,6 +286,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/setup/deployment.go b/setup/deployment.go index 068b777c..e6ff56ec 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -341,6 +341,12 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c return nil, false } return newMiniMaxDualProtocolClient(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.OpenAICompat), true case "minimax_payg-direct": apiKey := FirstNonEmpty(deployment.APIKey, lookup("MINIMAX_PAYG_API_KEY")) if apiKey == "" { From 3761a15930fbc18b0c175e3eb823385a22abccfc Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 11:14:44 +0530 Subject: [PATCH 08/18] feat: add Agnes AI provider with OmitMaxTokens compat fix - Add OmitMaxTokens compat flag to skip max_tokens for pre-deduction providers - Add AgnesCompat config with OmitMaxTokens=true to match curl behavior - Register AgnesCompat in init() for OpenAI-compatible providers - Guard max_tokens in buildRequestBase by OmitMaxTokens check - Switch agnes-direct deployment from OpenAICompat to AgnesCompat - Mark Agnes pricing as unknown (PricingUnknown) instead of free (pre-deduction) - Fix tests for new pricing sentinel behavior --- catalog/live/agnes_test.go | 8 +++++--- catalog/live/fetchers_providers.go | 10 ++++++---- client/adapters/compat.go | 23 +++++++++++++++++++++++ client/adapters/openai.go | 22 ++++++++++++++-------- client/adapters/openai_test.go | 15 +++++++++++++++ client/compat.go | 1 + setup/deployment.go | 2 +- 7 files changed, 65 insertions(+), 16 deletions(-) diff --git a/catalog/live/agnes_test.go b/catalog/live/agnes_test.go index c8aa506e..9a4d9a4e 100644 --- a/catalog/live/agnes_test.go +++ b/catalog/live/agnes_test.go @@ -61,10 +61,12 @@ func TestFetchAgnes_Mock(t *testing.T) { t.Errorf("expected agnes-2.0-pro, got %s", entries[1].ID) } - // Agnes is free — prices should be 0 + // Agnes exposes no per-token pricing and the Pro model uses a pre-deduction + // balance hold, so pricing is marked unknown (negative sentinel) rather + // than assumed free. PricingFromEntry maps this to PricingUnknown. for _, e := range entries { - if e.InputPricePer1M != 0 || e.OutputPricePer1M != 0 { - t.Errorf("expected zero pricing for free Agnes model %s, got in=%f out=%f", e.ID, e.InputPricePer1M, e.OutputPricePer1M) + if e.InputPricePer1M >= 0 || e.OutputPricePer1M >= 0 { + t.Errorf("expected unknown (negative) pricing for Agnes model %s, got in=%f out=%f", e.ID, e.InputPricePer1M, e.OutputPricePer1M) } } } diff --git a/catalog/live/fetchers_providers.go b/catalog/live/fetchers_providers.go index 10c5ddd6..f62617f2 100644 --- a/catalog/live/fetchers_providers.go +++ b/catalog/live/fetchers_providers.go @@ -591,7 +591,10 @@ func FetchPoolside(env map[string]string) ([]Entry, error) { } // FetchAgnes lists models from the Agnes AI OpenAI-compatible API. -// Agnes models are free with RPM limits. No per-token pricing. +// 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. func FetchAgnes(env map[string]string) ([]Entry, error) { entries, err := fetchOpenAICompatModels( context.Background(), @@ -601,10 +604,9 @@ func FetchAgnes(env map[string]string) ([]Entry, error) { if err != nil { return nil, err } - // Agnes is free tier — all models have zero per-token pricing for i := range entries { - entries[i].InputPricePer1M = 0 - entries[i].OutputPricePer1M = 0 + entries[i].InputPricePer1M = -1 + entries[i].OutputPricePer1M = -1 } return entries, nil } diff --git a/client/adapters/compat.go b/client/adapters/compat.go index 56c8b8c8..aab1c9b6 100644 --- a/client/adapters/compat.go +++ b/client/adapters/compat.go @@ -21,6 +21,12 @@ 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"` } // Per-provider compat configs. @@ -37,6 +43,19 @@ var ( ThinkingFormat: "openrouter", MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, } + // AgnesCompat: Agnes AI is a minimal OpenAI-compatible endpoint. 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. + AgnesCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + SupportsUsageInStreaming: true, + OmitMaxTokens: true, + } GeminiCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, } @@ -102,6 +121,10 @@ func init() { DynamicMu.Lock() defer DynamicMu.Unlock() + if p, ok := OpenAICompatibleProviders["agnes"]; ok { + p.Compat = &AgnesCompat + OpenAICompatibleProviders["agnes"] = p + } if p, ok := OpenAICompatibleProviders["grok"]; ok { p.Compat = &GrokCompat OpenAICompatibleProviders["grok"] = p diff --git a/client/adapters/openai.go b/client/adapters/openai.go index b3159410..c8ca0000 100644 --- a/client/adapters/openai.go +++ b/client/adapters/openai.go @@ -314,14 +314,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 { diff --git a/client/adapters/openai_test.go b/client/adapters/openai_test.go index 20ac817d..bf277e13 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) diff --git a/client/compat.go b/client/compat.go index 6a82f164..4886d492 100644 --- a/client/compat.go +++ b/client/compat.go @@ -8,6 +8,7 @@ type OpenAICompatConfig = adapters.OpenAICompatConfig // Per-provider compat configs. var ( OpenAICompat = adapters.OpenAICompat + AgnesCompat = adapters.AgnesCompat GrokCompat = adapters.GrokCompat OpenRouterCompat = adapters.OpenRouterCompat GeminiCompat = adapters.GeminiCompat diff --git a/setup/deployment.go b/setup/deployment.go index e6ff56ec..8367bdeb 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -346,7 +346,7 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c if apiKey == "" { return nil, false } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, "https://apihub.agnes-ai.com/v1"), &client.OpenAICompat), true + return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, "https://apihub.agnes-ai.com/v1"), &client.AgnesCompat), true case "minimax_payg-direct": apiKey := FirstNonEmpty(deployment.APIKey, lookup("MINIMAX_PAYG_API_KEY")) if apiKey == "" { From 11de5dd2e07d43e2b6f33431fb00eed017dc9423 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 12:10:27 +0530 Subject: [PATCH 09/18] feat: add Agnes-to-Anthropic fallback routing - Add agnesProviderStages with anthropic-direct fallback when both are configured - Add agnes-direct to deploymentOwnerProviderID - Add insufficient_user_quota/pre-deduction patterns to ShouldTryNextDeployment so Agnes balance errors trigger fallback to Anthropic --- config/routing_build.go | 29 +++++++++++++++++++++++++++++ router/retry.go | 2 ++ 2 files changed, 31 insertions(+) diff --git a/config/routing_build.go b/config/routing_build.go index a30a9557..cd815dd6 100644 --- a/config/routing_build.go +++ b/config/routing_build.go @@ -19,6 +19,9 @@ 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 := anthropicProviderStages(deployments); len(stages) > 0 { policy.Providers["anthropic"] = stages } @@ -92,6 +95,30 @@ func anthropicProviderStages(deployments map[string]DeploymentConfig) []RoutingS return stages } +func agnesProviderStages(deployments map[string]DeploymentConfig) []RoutingStage { + if _, ok := deployments["agnes-direct"]; !ok { + return nil + } + stages := []RoutingStage{{ + Deployments: []DeploymentChoice{{DeploymentID: "agnes-direct", Weight: 100}}, + Retries: 1, + }} + // If Anthropic is also configured, add it as a fallback stage. + // Agnes uses a pre-deduction balance hold; when the account balance is + // too low, the request fails with insufficient_user_quota. Falling back + // to Anthropic (or OpenRouter) lets the request succeed. + if _, ok := deployments["anthropic-direct"]; ok { + stages = append(stages, RoutingStage{ + Deployments: []DeploymentChoice{{DeploymentID: "anthropic-direct", Weight: 100}}, + Retries: 1, + }) + } + if _, ok := deployments["openrouter"]; ok { + stages = append(stages, openRouterFallbackStage()...) + } + return stages +} + func googleProviderStages(deployments map[string]DeploymentConfig) []RoutingStage { if _, ok := deployments["gemini-direct"]; ok { stages := singleDeploymentStages("gemini-direct", 1) @@ -130,6 +157,8 @@ func deploymentOwnerProviderID(deploymentID string) string { return "google" case "grok-direct": return "xai" + case "agnes-direct": + return "agnes" case "openrouter": return "openrouter" case "canopywave": 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 From d98c3826afd8bd0ef1a4519801ebaffcca2affb1 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 12:42:25 +0530 Subject: [PATCH 10/18] feat: add LongCat AI provider (OpenAI-compatible endpoint) LongCat AI provides LongCat-2.0 model with 1M token context. Supports both Token Pack and Pay-As-You-Go plans via same endpoint. Endpoint: https://api.longcat.chat/openai/v1 Uses OpenAICompat config. Added live model fetcher, routing, and profiles. --- catalog/live/fetchers.go | 1 + catalog/live/fetchers_providers.go | 19 +++++++++++++++++++ catalog/provider_live_parity_test.go | 4 ++-- catalog/registry/provider_spec_test.go | 8 ++++---- catalog/registry/providers.go | 8 ++++++++ config/profiles.go | 3 ++- config/routing_build.go | 16 ++++++++++++++++ setup/deployment.go | 6 ++++++ 8 files changed, 58 insertions(+), 7 deletions(-) diff --git a/catalog/live/fetchers.go b/catalog/live/fetchers.go index 6eeaba95..2d72175f 100644 --- a/catalog/live/fetchers.go +++ b/catalog/live/fetchers.go @@ -72,6 +72,7 @@ var Registry = map[string]FetchFunc{ "zai_coding": FetchZAICoding, "concentrate": FetchConcentrate, "agnes": FetchAgnes, + "longcat": FetchLongCat, "canopywave": FetchCanopyWave, "opencodego": FetchOpenCodeGo, "kimi": FetchKimi, diff --git a/catalog/live/fetchers_providers.go b/catalog/live/fetchers_providers.go index f62617f2..ea8e80f9 100644 --- a/catalog/live/fetchers_providers.go +++ b/catalog/live/fetchers_providers.go @@ -611,6 +611,25 @@ func FetchAgnes(env map[string]string) ([]Entry, error) { return entries, nil } +// FetchLongCat lists models from the LongCat OpenAI-compatible API. +// LongCat supports both Token Pack and Pay-As-You-Go plans; pricing is not +// exposed in the /models response, so it is marked unknown. +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 + } + return entries, nil +} + // FetchConcentrate lists models from the public Concentrate AI model catalog. // Concentrate returns a rich format with max_input_tokens, max_tokens, and capabilities. // Pricing is fetched from individual model endpoints and cached to disk for 24 hours. diff --git a/catalog/provider_live_parity_test.go b/catalog/provider_live_parity_test.go index e46ec7ef..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) != 24 { - t.Fatalf("expected 24 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/provider_spec_test.go b/catalog/registry/provider_spec_test.go index ea02cdad..cfdeb1c1 100644 --- a/catalog/registry/provider_spec_test.go +++ b/catalog/registry/provider_spec_test.go @@ -9,8 +9,8 @@ import ( func TestAllProviders_Count(t *testing.T) { t.Parallel() - if n := len(registry.All()); n != 24 { - t.Fatalf("expected 24 providers, got %d", n) + if n := len(registry.All()); n != 25 { + t.Fatalf("expected 25 providers, got %d", n) } } @@ -24,8 +24,8 @@ func TestCredentialRegistry_MatchesAll(t *testing.T) { func TestLiveFetcherKeys_AllProviders(t *testing.T) { t.Parallel() keys := registry.LiveFetcherKeys() - if len(keys) != 24 { - t.Fatalf("expected 24 live fetcher keys, got %d", len(keys)) + if len(keys) != 25 { + t.Fatalf("expected 25 live fetcher keys, got %d", len(keys)) } } diff --git a/catalog/registry/providers.go b/catalog/registry/providers.go index 08c16627..e2838957 100644 --- a/catalog/registry/providers.go +++ b/catalog/registry/providers.go @@ -181,6 +181,14 @@ func providerSpecs() []ProviderSpec { LiveFetcherKey: "agnes", LiveCatalogKey: "agnes", ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "agnes", }, + { + ProviderID: "longcat", DisplayName: "LongCat", DeploymentID: "longcat-direct", SortOrder: 18, 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 ───────────────────────────────────────────────────────── { diff --git a/config/profiles.go b/config/profiles.go index 09ca7e45..84f7312b 100644 --- a/config/profiles.go +++ b/config/profiles.go @@ -255,13 +255,14 @@ const ( // OpenAICompatibleRuntimeProfileOrder is the detection order for runtime profiles. var OpenAICompatibleRuntimeProfileOrder = []string{ - "concentrate", "agnes", "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, diff --git a/config/routing_build.go b/config/routing_build.go index cd815dd6..a0ba5674 100644 --- a/config/routing_build.go +++ b/config/routing_build.go @@ -22,6 +22,9 @@ func BuildRoutingPolicyFromDeployments(deployments map[string]DeploymentConfig) 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 } @@ -119,6 +122,17 @@ func agnesProviderStages(deployments map[string]DeploymentConfig) []RoutingStage return stages } +func longcatProviderStages(deployments map[string]DeploymentConfig) []RoutingStage { + if _, ok := deployments["longcat-direct"]; !ok { + return nil + } + stages := singleDeploymentStages("longcat-direct", 1) + if _, ok := deployments["openrouter"]; ok { + stages = append(stages, openRouterFallbackStage()...) + } + return stages +} + func googleProviderStages(deployments map[string]DeploymentConfig) []RoutingStage { if _, ok := deployments["gemini-direct"]; ok { stages := singleDeploymentStages("gemini-direct", 1) @@ -157,6 +171,8 @@ func deploymentOwnerProviderID(deploymentID string) string { return "google" case "grok-direct": return "xai" + case "longcat-direct": + return "longcat" case "agnes-direct": return "agnes" case "openrouter": diff --git a/setup/deployment.go b/setup/deployment.go index 8367bdeb..9cf4d807 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -347,6 +347,12 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c 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.OpenAICompat), true case "minimax_payg-direct": apiKey := FirstNonEmpty(deployment.APIKey, lookup("MINIMAX_PAYG_API_KEY")) if apiKey == "" { From b8d2ed96e63147af665c4bc970b9b05f15959dd4 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 12:49:13 +0530 Subject: [PATCH 11/18] feat: add longcat-anthropic fallback deployment LongCat supports both OpenAI and Anthropic API formats. If the OpenAI-compatible endpoint fails, fall back to the Anthropic endpoint at https://api.longcat.chat/anthropic/v1/messages --- config/routing_build.go | 9 +++++++++ setup/deployment.go | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/config/routing_build.go b/config/routing_build.go index a0ba5674..7ed1830d 100644 --- a/config/routing_build.go +++ b/config/routing_build.go @@ -127,6 +127,13 @@ func longcatProviderStages(deployments map[string]DeploymentConfig) []RoutingSta return nil } stages := singleDeploymentStages("longcat-direct", 1) + // Fallback to LongCat's Anthropic-compatible endpoint if OpenAI fails + if _, ok := deployments["longcat-anthropic"]; ok { + stages = append(stages, RoutingStage{ + Deployments: []DeploymentChoice{{DeploymentID: "longcat-anthropic", Weight: 100}}, + Retries: 1, + }) + } if _, ok := deployments["openrouter"]; ok { stages = append(stages, openRouterFallbackStage()...) } @@ -173,6 +180,8 @@ func deploymentOwnerProviderID(deploymentID string) string { return "xai" case "longcat-direct": return "longcat" + case "longcat-anthropic": + return "longcat" case "agnes-direct": return "agnes" case "openrouter": diff --git a/setup/deployment.go b/setup/deployment.go index 9cf4d807..a6d9ec86 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -353,6 +353,12 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c return nil, false } return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, "https://api.longcat.chat/openai/v1"), &client.OpenAICompat), true + case "longcat-anthropic": + apiKey := FirstNonEmpty(deployment.APIKey, lookup("LONGCAT_API_KEY")) + if apiKey == "" { + return nil, false + } + return client.NewAnthropicClient(apiKey, FirstNonEmpty(deployment.BaseURL, "https://api.longcat.chat/anthropic")), true case "minimax_payg-direct": apiKey := FirstNonEmpty(deployment.APIKey, lookup("MINIMAX_PAYG_API_KEY")) if apiKey == "" { From 2999be60aace9263831b59233d205da9fd371e5b Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 12:54:56 +0530 Subject: [PATCH 12/18] fix: remove OpenRouter fallback from LongCat routing LongCat should only fall back between its own OpenAI and Anthropic endpoints, not to OpenRouter. --- config/routing_build.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/config/routing_build.go b/config/routing_build.go index 7ed1830d..6005600b 100644 --- a/config/routing_build.go +++ b/config/routing_build.go @@ -134,9 +134,6 @@ func longcatProviderStages(deployments map[string]DeploymentConfig) []RoutingSta Retries: 1, }) } - if _, ok := deployments["openrouter"]; ok { - stages = append(stages, openRouterFallbackStage()...) - } return stages } From 1cdea5eca17a60e626630ba34c6f65c384c0f9db Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 13:17:01 +0530 Subject: [PATCH 13/18] fix: alphabetical SortOrder values for all providers Reorder all provider SortOrder values to alphabetical by DisplayName. Fixes duplicate SortOrder values that were introduced during renumbering. --- catalog/registry/providers.go | 50 +++++++++++++++++------------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/catalog/registry/providers.go b/catalog/registry/providers.go index e2838957..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,7 +165,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "openrouter", RuntimeProfileKey: "openrouter", }, { - ProviderID: "concentrate", DisplayName: "Concentrate AI (Pay-as-you-go)", DeploymentID: "concentrate-payg", 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", @@ -174,7 +174,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-responses", AdapterID: "concentrate-responses", RuntimeProfileKey: "concentrate", }, { - ProviderID: "agnes", DisplayName: "Agnes AI", DeploymentID: "agnes-direct", SortOrder: 17, ChatPreference: 25, + 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", @@ -182,7 +182,7 @@ func providerSpecs() []ProviderSpec { ProtocolID: "openai-chat-completions", AdapterID: "openai", RuntimeProfileKey: "agnes", }, { - ProviderID: "longcat", DisplayName: "LongCat", DeploymentID: "longcat-direct", SortOrder: 18, ChatPreference: 26, + 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", @@ -192,7 +192,7 @@ func providerSpecs() []ProviderSpec { // ── 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", @@ -200,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", @@ -208,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", @@ -216,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"}, @@ -225,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, @@ -236,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"}, From 18e452d5b7e232b351bad5f4e8295fff1bd10af7 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 15:59:25 +0530 Subject: [PATCH 14/18] fix: keep Agnes and LongCat OpenAI-only with full live capabilities Drop cross-provider and longcat-anthropic fallbacks, map longcat-direct credentials, and enrich official context/tools/vision/thinking metadata from provider docs when /models omits it. Co-authored-by: Cursor --- catalog/live/agnes_test.go | 17 ++++++ catalog/live/fetchers.go | 3 +- catalog/live/fetchers_providers.go | 74 ++++++++++++++++++++++- catalog/live/longcat_test.go | 81 ++++++++++++++++++++++++++ catalog/registry/provider_spec_test.go | 21 ++++++- catalog/v1.go | 68 ++++++++++++++++++++- catalog/v1_test.go | 26 +++++++++ client/adapters/compat.go | 15 ++++- client/adapters/openai.go | 16 +++-- client/adapters/openai_test.go | 16 +++++ client/compat.go | 1 + client/compat_test.go | 2 + client/provider_policy.go | 6 +- client/provider_policy_test.go | 8 ++- config/deployment_env_sync_test.go | 20 +++++++ config/provider_secrets.go | 1 + config/provider_secrets_test.go | 12 ++++ config/routing_build.go | 33 ++--------- setup/deployment.go | 8 +-- 19 files changed, 375 insertions(+), 53 deletions(-) create mode 100644 catalog/live/longcat_test.go diff --git a/catalog/live/agnes_test.go b/catalog/live/agnes_test.go index 9a4d9a4e..41e9fe68 100644 --- a/catalog/live/agnes_test.go +++ b/catalog/live/agnes_test.go @@ -61,6 +61,23 @@ func TestFetchAgnes_Mock(t *testing.T) { 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) + } + 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) + } + // Agnes exposes no per-token pricing and the Pro model uses a pre-deduction // balance hold, so pricing is marked unknown (negative sentinel) rather // than assumed free. PricingFromEntry maps this to PricingUnknown. diff --git a/catalog/live/fetchers.go b/catalog/live/fetchers.go index 2d72175f..a61de7d1 100644 --- a/catalog/live/fetchers.go +++ b/catalog/live/fetchers.go @@ -104,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"` @@ -258,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 ea8e80f9..5f74091b 100644 --- a/catalog/live/fetchers_providers.go +++ b/catalog/live/fetchers_providers.go @@ -595,6 +595,11 @@ func FetchPoolside(env map[string]string) ([]Entry, error) { // 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(), @@ -607,13 +612,56 @@ func FetchAgnes(env map[string]string) ([]Entry, error) { for i := range entries { entries[i].InputPricePer1M = -1 entries[i].OutputPricePer1M = -1 + entries[i] = enrichAgnesEntry(entries[i]) } return entries, nil } -// FetchLongCat lists models from the LongCat OpenAI-compatible API. -// LongCat supports both Token Pack and Pay-As-You-Go plans; pricing is not -// exposed in the /models response, so it is marked unknown. +// Official Agnes text-model reference when /models omits context/capabilities. +// Image/video models use separate generation endpoints and are left as-is. +var agnesOfficialTextSpecs = map[string]struct { + ContextWindow, MaxOutput int + Tools, Vision, Thinking bool +}{ + "agnes-1.5-flash": {256_000, 65_536, true, true, false}, + "agnes-2.0-flash": {512_000, 65_536, true, true, true}, + "agnes-2.5-flash": {512_000, 65_536, true, true, true}, + "agnes-2.5-pro-alpha": {1_048_576, 65_536, true, true, true}, +} + +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") + } + 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(), @@ -626,10 +674,30 @@ func FetchLongCat(env map[string]string) ([]Entry, error) { 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. // Pricing is fetched from individual model endpoints and cached to disk for 24 hours. 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/registry/provider_spec_test.go b/catalog/registry/provider_spec_test.go index cfdeb1c1..0a627197 100644 --- a/catalog/registry/provider_spec_test.go +++ b/catalog/registry/provider_spec_test.go @@ -14,10 +14,25 @@ func TestAllProviders_Count(t *testing.T) { } } -func TestCredentialRegistry_MatchesAll(t *testing.T) { +func TestProviderSpecs_AgnesAndLongCatOpenAIOnly(t *testing.T) { t.Parallel() - if len(registry.CredentialRegistry()) != len(registry.All()) { - t.Fatal("credential registry should cover all provider specs") + for _, id := range []string{"agnes", "longcat"} { + spec, ok := registry.SpecByProviderID(id) + if !ok { + t.Fatalf("missing provider %q", id) + } + if spec.ProtocolID != "openai-chat-completions" { + t.Fatalf("%s ProtocolID = %q, want openai-chat-completions", id, spec.ProtocolID) + } + if spec.AdapterID != "openai" { + t.Fatalf("%s AdapterID = %q, want openai", id, spec.AdapterID) + } + if spec.ProbeKind != registry.ProbeOpenAIModels { + t.Fatalf("%s ProbeKind = %v, want ProbeOpenAIModels", id, spec.ProbeKind) + } + if got := registry.DirectFallbackProviderIDs(id); len(got) != 0 { + t.Fatalf("%s DirectFallbacks = %v, want none", id, got) + } } } 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_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/client/adapters/compat.go b/client/adapters/compat.go index aab1c9b6..15ce91fa 100644 --- a/client/adapters/compat.go +++ b/client/adapters/compat.go @@ -43,7 +43,7 @@ var ( ThinkingFormat: "openrouter", MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, } - // AgnesCompat: Agnes AI is a minimal OpenAI-compatible endpoint. It does + // 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 @@ -51,10 +51,19 @@ var ( // 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. + LongCatCompat = OpenAICompatConfig{ + MaxTokensField: "max_tokens", + SupportsUsageInStreaming: true, + ThinkingFormat: "zai", // same thinking object wire shape as LongCat docs } GeminiCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, @@ -125,6 +134,10 @@ func init() { 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 diff --git a/client/adapters/openai.go b/client/adapters/openai.go index c8ca0000..ef9998de 100644 --- a/client/adapters/openai.go +++ b/client/adapters/openai.go @@ -121,6 +121,7 @@ type openaiRequest struct { ResponseFormat interface{} `json:"response_format,omitempty"` ReasoningEffort string `json:"reasoning_effort,omitempty"` Thinking map[string]interface{} `json:"thinking,omitempty"` + ChatTemplateKwargs map[string]interface{} `json:"chat_template_kwargs,omitempty"` Stop interface{} `json:"stop,omitempty"` ServiceTier string `json:"service_tier,omitempty"` User string `json:"user,omitempty"` @@ -408,12 +409,17 @@ 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 && opts.GLMThinkingEnabled != nil { + switch compat.ThinkingFormat { + case "zai": + thinkingType := "disabled" + if *opts.GLMThinkingEnabled { + thinkingType = "enabled" + } + req.Thinking = map[string]interface{}{"type": thinkingType} + case "agnes": + req.ChatTemplateKwargs = map[string]interface{}{"enable_thinking": *opts.GLMThinkingEnabled} } - req.Thinking = map[string]interface{}{"type": thinkingType} } return req } diff --git a/client/adapters/openai_test.go b/client/adapters/openai_test.go index bf277e13..16f0cefd 100644 --- a/client/adapters/openai_test.go +++ b/client/adapters/openai_test.go @@ -447,6 +447,22 @@ func TestBuildRequestBase_ZAIThinking(t *testing.T) { } } +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 TestOpenAIClient_Ping_MimoAuthRetry(t *testing.T) { t.Parallel() calls := 0 diff --git a/client/compat.go b/client/compat.go index 4886d492..8605d1f6 100644 --- a/client/compat.go +++ b/client/compat.go @@ -9,6 +9,7 @@ type OpenAICompatConfig = adapters.OpenAICompatConfig var ( OpenAICompat = adapters.OpenAICompat AgnesCompat = adapters.AgnesCompat + LongCatCompat = adapters.LongCatCompat GrokCompat = adapters.GrokCompat OpenRouterCompat = adapters.OpenRouterCompat GeminiCompat = adapters.GeminiCompat diff --git a/client/compat_test.go b/client/compat_test.go index 2b5c7450..9318b7b9 100644 --- a/client/compat_test.go +++ b/client/compat_test.go @@ -113,6 +113,8 @@ func TestCompatMatrixThinkingFormatValues(t *testing.T) { {&OpenRouterCompat, "openrouter"}, {&ZAICompat, "zai"}, {&OpenCodeGoCompat, "openrouter"}, + {&AgnesCompat, "agnes"}, + {&LongCatCompat, "zai"}, } for _, tc := range tests { if tc.compat.ThinkingFormat != tc.format { diff --git a/client/provider_policy.go b/client/provider_policy.go index f619835b..cec33b19 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,7 +13,10 @@ func ApplyProviderChatDefaults(provider string, opts ChatOptions) ChatOptions { if strings.EqualFold(strings.TrimSpace(provider), "anthropic") { opts.EnableCaching = true } - if !config.IsZAIProvider(provider) { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "zai_payg", "zai_coding", "longcat", "agnes": + // Keep GLMThinkingEnabled — these providers use thinking / chat_template_kwargs. + default: opts.GLMThinkingEnabled = nil } return opts diff --git a/client/provider_policy_test.go b/client/provider_policy_test.go index f3df322e..3fa0792a 100644 --- a/client/provider_policy_test.go +++ b/client/provider_policy_test.go @@ -18,8 +18,14 @@ func TestApplyProviderChatDefaults(t *testing.T) { if opts := ApplyProviderChatDefaults("zai_payg", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled == nil { t.Fatal("Z.AI thinking preference was removed") } + if opts := ApplyProviderChatDefaults("longcat", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled == nil { + t.Fatal("LongCat thinking preference was removed") + } + if opts := ApplyProviderChatDefaults("agnes", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled == nil { + t.Fatal("Agnes thinking preference was removed") + } if opts := ApplyProviderChatDefaults("openai", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled != nil { - t.Fatal("non-Z.AI thinking preference was retained") + t.Fatal("non-thinking provider thinking preference was retained") } } diff --git a/config/deployment_env_sync_test.go b/config/deployment_env_sync_test.go index 477ff6b3..8c221696 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/provider_secrets.go b/config/provider_secrets.go index ca6ce8ae..3139d14b 100644 --- a/config/provider_secrets.go +++ b/config/provider_secrets.go @@ -141,6 +141,7 @@ func legacyDeploymentCredentialEnv(deploymentID string) string { "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", 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/routing_build.go b/config/routing_build.go index 6005600b..a7f5bf42 100644 --- a/config/routing_build.go +++ b/config/routing_build.go @@ -102,39 +102,16 @@ func agnesProviderStages(deployments map[string]DeploymentConfig) []RoutingStage if _, ok := deployments["agnes-direct"]; !ok { return nil } - stages := []RoutingStage{{ - Deployments: []DeploymentChoice{{DeploymentID: "agnes-direct", Weight: 100}}, - Retries: 1, - }} - // If Anthropic is also configured, add it as a fallback stage. - // Agnes uses a pre-deduction balance hold; when the account balance is - // too low, the request fails with insufficient_user_quota. Falling back - // to Anthropic (or OpenRouter) lets the request succeed. - if _, ok := deployments["anthropic-direct"]; ok { - stages = append(stages, RoutingStage{ - Deployments: []DeploymentChoice{{DeploymentID: "anthropic-direct", Weight: 100}}, - Retries: 1, - }) - } - if _, ok := deployments["openrouter"]; ok { - stages = append(stages, openRouterFallbackStage()...) - } - return stages + // 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 } - stages := singleDeploymentStages("longcat-direct", 1) - // Fallback to LongCat's Anthropic-compatible endpoint if OpenAI fails - if _, ok := deployments["longcat-anthropic"]; ok { - stages = append(stages, RoutingStage{ - Deployments: []DeploymentChoice{{DeploymentID: "longcat-anthropic", Weight: 100}}, - Retries: 1, - }) - } - return stages + // Single OpenAI-compatible endpoint only (longcat-direct). + return singleDeploymentStages("longcat-direct", 1) } func googleProviderStages(deployments map[string]DeploymentConfig) []RoutingStage { @@ -177,8 +154,6 @@ func deploymentOwnerProviderID(deploymentID string) string { return "xai" case "longcat-direct": return "longcat" - case "longcat-anthropic": - return "longcat" case "agnes-direct": return "agnes" case "openrouter": diff --git a/setup/deployment.go b/setup/deployment.go index a6d9ec86..400a08e0 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -352,13 +352,7 @@ func providerForDeployment(id string, deployment config.DeploymentConfig, cfg *c if apiKey == "" { return nil, false } - return client.NewOpenAIClient(apiKey, FirstNonEmpty(deployment.BaseURL, "https://api.longcat.chat/openai/v1"), &client.OpenAICompat), true - case "longcat-anthropic": - apiKey := FirstNonEmpty(deployment.APIKey, lookup("LONGCAT_API_KEY")) - if apiKey == "" { - return nil, false - } - return client.NewAnthropicClient(apiKey, FirstNonEmpty(deployment.BaseURL, "https://api.longcat.chat/anthropic")), true + 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 == "" { From a5bf10456ec658464d34318d573768e6f6333db6 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 19:13:11 +0530 Subject: [PATCH 15/18] fix: complete changes for Agnes/LongCat OpenAI-only capabilities --- catalog/live/agnes_test.go | 11 +- catalog/live/fetchers_providers.go | 18 +- catalog/registry/protocol_matrix_test.go | 87 +++++ catalog/registry/provider_spec_test.go | 39 ++- catalog/xiaomi/endpoints.go | 33 +- catalog/xiaomi/endpoints_test.go | 20 -- catalog/zai/endpoints.go | 21 +- catalog/zai/endpoints_test.go | 21 -- client/adapters/anthropic.go | 16 + client/adapters/anthropic_base.go | 49 +++ client/adapters/anthropic_base_test.go | 102 ++++++ client/adapters/anthropic_test.go | 47 ++- client/adapters/compat.go | 50 ++- client/adapters/deepseek.go | 52 +-- client/adapters/deepseek_test.go | 253 ++------------ client/adapters/mimo.go | 109 ++---- client/adapters/mimo_test.go | 253 ++++---------- client/adapters/openai.go | 89 +++-- client/adapters/openai_test.go | 81 +++++ client/adapters/opencodego.go | 62 ++-- client/adapters/opencodego_test.go | 175 ++-------- client/adapters/protocol_router.go | 241 ------------- client/adapters/protocol_router_test.go | 425 ----------------------- client/adapters/zai.go | 99 +----- client/adapters/zai_test.go | 298 ++-------------- client/aliases.go | 24 +- client/compat.go | 1 + client/compat_test.go | 14 +- client/mimo_test.go | 10 - client/opencodego_test.go | 19 - client/protocol_router_test.go | 15 - client/provider_policy.go | 20 +- client/provider_policy_test.go | 27 +- client/provider_registry.go | 9 +- client/thinking_policy.go | 74 ++++ config/providers.go | 3 +- config/routing_build.go | 1 + config/xiaomi_profile.go | 10 - config/zai_profile.go | 14 - engine/convert.go | 7 + setup/deployment.go | 47 +-- 41 files changed, 883 insertions(+), 2063 deletions(-) create mode 100644 catalog/registry/protocol_matrix_test.go create mode 100644 client/adapters/anthropic_base.go create mode 100644 client/adapters/anthropic_base_test.go delete mode 100644 client/adapters/protocol_router.go delete mode 100644 client/adapters/protocol_router_test.go delete mode 100644 client/protocol_router_test.go create mode 100644 client/thinking_policy.go diff --git a/catalog/live/agnes_test.go b/catalog/live/agnes_test.go index 41e9fe68..9dbe0bb0 100644 --- a/catalog/live/agnes_test.go +++ b/catalog/live/agnes_test.go @@ -68,6 +68,9 @@ func TestFetchAgnes_Mock(t *testing.T) { 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" { @@ -78,12 +81,10 @@ func TestFetchAgnes_Mock(t *testing.T) { t.Fatalf("agnes-2.0-flash missing tools feature: %v", flash.Features) } - // Agnes exposes no per-token pricing and the Pro model uses a pre-deduction - // balance hold, so pricing is marked unknown (negative sentinel) rather - // than assumed free. PricingFromEntry maps this to PricingUnknown. + // Unknown models keep unknown pricing; flash enrichment overrides to free. for _, e := range entries { - if e.InputPricePer1M >= 0 || e.OutputPricePer1M >= 0 { - t.Errorf("expected unknown (negative) pricing for Agnes model %s, got in=%f out=%f", e.ID, e.InputPricePer1M, e.OutputPricePer1M) + 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) } } } diff --git a/catalog/live/fetchers_providers.go b/catalog/live/fetchers_providers.go index 5f74091b..22a6580a 100644 --- a/catalog/live/fetchers_providers.go +++ b/catalog/live/fetchers_providers.go @@ -619,14 +619,16 @@ func FetchAgnes(env map[string]string) ([]Entry, error) { // 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 + 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}, - "agnes-2.0-flash": {512_000, 65_536, true, true, true}, - "agnes-2.5-flash": {512_000, 65_536, true, true, true}, - "agnes-2.5-pro-alpha": {1_048_576, 65_536, true, true, true}, + "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 { @@ -655,6 +657,10 @@ func enrichAgnesEntry(e Entry) Entry { 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 } 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 0a627197..1aa797be 100644 --- a/catalog/registry/provider_spec_test.go +++ b/catalog/registry/provider_spec_test.go @@ -14,25 +14,28 @@ func TestAllProviders_Count(t *testing.T) { } } -func TestProviderSpecs_AgnesAndLongCatOpenAIOnly(t *testing.T) { +func TestProviderSpecs_AgnesOpenAIOnlyLongCatOpenAIPrimary(t *testing.T) { t.Parallel() - for _, id := range []string{"agnes", "longcat"} { - spec, ok := registry.SpecByProviderID(id) - if !ok { - t.Fatalf("missing provider %q", id) - } - if spec.ProtocolID != "openai-chat-completions" { - t.Fatalf("%s ProtocolID = %q, want openai-chat-completions", id, spec.ProtocolID) - } - if spec.AdapterID != "openai" { - t.Fatalf("%s AdapterID = %q, want openai", id, spec.AdapterID) - } - if spec.ProbeKind != registry.ProbeOpenAIModels { - t.Fatalf("%s ProbeKind = %v, want ProbeOpenAIModels", id, spec.ProbeKind) - } - if got := registry.DirectFallbackProviderIDs(id); len(got) != 0 { - t.Fatalf("%s DirectFallbacks = %v, want none", id, got) - } + // 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) } } 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 15ce91fa..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. @@ -27,6 +27,11 @@ type OpenAICompatConfig struct { // 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. @@ -40,7 +45,9 @@ 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 @@ -60,10 +67,13 @@ var ( } // 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: "zai", // same thinking object wire shape as LongCat docs + ThinkingFormat: "longcat", + DefaultDisableThinking: true, } GeminiCompat = OpenAICompatConfig{ MaxTokensField: "max_tokens", SupportsUsageInStreaming: true, @@ -81,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", @@ -96,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", @@ -110,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() { @@ -198,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/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 ef9998de..7885fc09 100644 --- a/client/adapters/openai.go +++ b/client/adapters/openai.go @@ -121,22 +121,26 @@ type openaiRequest struct { ResponseFormat interface{} `json:"response_format,omitempty"` ReasoningEffort string `json:"reasoning_effort,omitempty"` Thinking map[string]interface{} `json:"thinking,omitempty"` - ChatTemplateKwargs map[string]interface{} `json:"chat_template_kwargs,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 { @@ -409,16 +413,51 @@ func buildRequestBase(messages []core.EyrieMessage, opts core.ChatOptions, strea if len(opts.Metadata) > 0 { req.Metadata = opts.Metadata } - if compat != nil && opts.GLMThinkingEnabled != nil { - switch compat.ThinkingFormat { - case "zai": - 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} - case "agnes": - req.ChatTemplateKwargs = map[string]interface{}{"enable_thinking": *opts.GLMThinkingEnabled} } } return req diff --git a/client/adapters/openai_test.go b/client/adapters/openai_test.go index 16f0cefd..81b4364e 100644 --- a/client/adapters/openai_test.go +++ b/client/adapters/openai_test.go @@ -447,6 +447,24 @@ 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 @@ -463,6 +481,69 @@ func TestBuildRequestBase_AgnesThinking(t *testing.T) { } } +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..6795b902 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,23 @@ 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) + // Health-check the OpenAI gateway surface; Anthropic-only models share the same host. + return c.openai.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") -} - -// 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/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 8605d1f6..1d562308 100644 --- a/client/compat.go +++ b/client/compat.go @@ -27,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 9318b7b9..2d070c04 100644 --- a/client/compat_test.go +++ b/client/compat_test.go @@ -114,20 +114,28 @@ func TestCompatMatrixThinkingFormatValues(t *testing.T) { {&ZAICompat, "zai"}, {&OpenCodeGoCompat, "openrouter"}, {&AgnesCompat, "agnes"}, - {&LongCatCompat, "zai"}, + {&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 cec33b19..ed07d090 100644 --- a/client/provider_policy.go +++ b/client/provider_policy.go @@ -13,11 +13,25 @@ func ApplyProviderChatDefaults(provider string, opts ChatOptions) ChatOptions { if strings.EqualFold(strings.TrimSpace(provider), "anthropic") { opts.EnableCaching = true } + opts = NormalizeThinkingOptions(opts) switch strings.ToLower(strings.TrimSpace(provider)) { - case "zai_payg", "zai_coding", "longcat", "agnes": - // Keep GLMThinkingEnabled — these providers use thinking / chat_template_kwargs. + 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: - opts.GLMThinkingEnabled = nil + 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 3fa0792a..c5f9f72e 100644 --- a/client/provider_policy_test.go +++ b/client/provider_policy_test.go @@ -15,16 +15,31 @@ 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("longcat", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled == nil { - t.Fatal("LongCat thinking preference was removed") + if opts := ApplyProviderChatDefaults("longcat", ChatOptions{ThinkingEnabled: &enabled}); opts.ThinkingEnabled == nil || !*opts.ThinkingEnabled { + t.Fatal("LongCat explicit thinking preference was removed") } - if opts := ApplyProviderChatDefaults("agnes", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled == nil { - t.Fatal("Agnes 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("openai", ChatOptions{GLMThinkingEnabled: &enabled}); opts.GLMThinkingEnabled != nil { + 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/providers.go b/config/providers.go index 302b3d42..941fd683 100644 --- a/config/providers.go +++ b/config/providers.go @@ -36,8 +36,7 @@ const ( DefaultMiniMaxOpenAIBaseURL = "https://api.minimax.io/v1" 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" + DefaultClinePassOpenAIBaseURL = "https://api.cline.bot/api/v1" // #nosec G101 -- public API base URL, not a secret value ) // ProviderTransport is the transport type for provider requests. diff --git a/config/routing_build.go b/config/routing_build.go index a7f5bf42..dfb6cc78 100644 --- a/config/routing_build.go +++ b/config/routing_build.go @@ -111,6 +111,7 @@ func longcatProviderStages(deployments map[string]DeploymentConfig) []RoutingSta 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) } 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/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/setup/deployment.go b/setup/deployment.go index 400a08e0..13fc2149 100644 --- a/setup/deployment.go +++ b/setup/deployment.go @@ -292,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 == "" { @@ -340,7 +339,7 @@ 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 == "" { @@ -358,7 +357,7 @@ 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 default: return nil, false } @@ -379,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 == "" { @@ -403,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) { @@ -421,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 { @@ -586,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. From b090ac8c49bd2801e4db4eb32c505d5527fe0a79 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 21:55:54 +0530 Subject: [PATCH 16/18] fix: remove invalid ChatOptions field accesses in concentrate adapter and update credential resolve test --- catalog/live/agnes_test.go | 2 +- client/adapters/concentrate_responses.go | 100 ++++++----------------- client/adapters/opencodego.go | 1 - config/credential/resolve_test.go | 4 +- config/deployment_env_sync_test.go | 8 +- config/providers.go | 2 +- 6 files changed, 35 insertions(+), 82 deletions(-) diff --git a/catalog/live/agnes_test.go b/catalog/live/agnes_test.go index 9dbe0bb0..d37caa75 100644 --- a/catalog/live/agnes_test.go +++ b/catalog/live/agnes_test.go @@ -111,7 +111,7 @@ func TestFetchAgnes_Unauthorized(t *testing.T) { defer server.Close() _, err := FetchAgnes(map[string]string{ - "AGNES_API_KEY": "invalid-key", + "AGNES_API_KEY": "invalid-key", "AGNES_BASE_URL": server.URL, }) if err == nil { diff --git a/client/adapters/concentrate_responses.go b/client/adapters/concentrate_responses.go index 70cba988..4a6be3da 100644 --- a/client/adapters/concentrate_responses.go +++ b/client/adapters/concentrate_responses.go @@ -48,34 +48,34 @@ func (c *ConcentrateResponsesClient) Name() string { return "concentrate" } // responsesRequest is the request body for the Concentrate Responses API. type responsesRequest struct { - Input interface{} `json:"input"` - Model string `json:"model"` - Instructions string `json:"instructions,omitempty"` - MaxOutputTokens int `json:"max_output_tokens,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - Stream bool `json:"stream,omitempty"` - Reasoning *responsesReasoning `json:"reasoning,omitempty"` - Tools []responsesTool `json:"tools,omitempty"` - ToolChoice interface{} `json:"tool_choice,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"` + Input interface{} `json:"input"` + Model string `json:"model"` + Instructions string `json:"instructions,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Stream bool `json:"stream,omitempty"` + Reasoning *responsesReasoning `json:"reasoning,omitempty"` + Tools []responsesTool `json:"tools,omitempty"` + ToolChoice interface{} `json:"tool_choice,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"` + 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 { @@ -328,55 +328,9 @@ func (c *ConcentrateResponsesClient) buildRequest(messages []core.EyrieMessage, } // Map extended Responses API parameters - if opts.PreviousResponseID != "" { - req.PreviousResponseID = opts.PreviousResponseID - } - if len(opts.Include) > 0 { - req.Include = opts.Include - } - if opts.Routing != nil { - req.Routing = &routingConfig{ - Model: opts.Routing.Model, - Strategy: opts.Routing.Strategy, - Metric: opts.Routing.Metric, - } - } - if opts.MaxToolCalls != nil { - req.MaxToolCalls = opts.MaxToolCalls - } - if len(opts.ContextManagement) > 0 { - req.ContextManagement = make([]contextManagement, 0, len(opts.ContextManagement)) - for _, cm := range opts.ContextManagement { - req.ContextManagement = append(req.ContextManagement, contextManagement{Type: cm.Type}) - } - } - if opts.SafetyIdentifier != nil { - req.SafetyIdentifier = opts.SafetyIdentifier - } - if opts.Background != nil { - req.Background = opts.Background - } - if opts.Truncation != nil { - req.Truncation = opts.Truncation - } - if opts.Conversation != nil { - req.Conversation = opts.Conversation - } if opts.ServiceTier != "" { req.ServiceTier = opts.ServiceTier } - if opts.PromptCacheOptions != nil { - req.PromptCacheOptions = &promptCacheOptions{ - Mode: opts.PromptCacheOptions.Mode, - TTL: opts.PromptCacheOptions.TTL, - } - } - if opts.CacheControl != nil { - req.CacheControl = &cacheControl{ - Type: opts.CacheControl.Type, - TTL: opts.CacheControl.TTL, - } - } return req, nil } diff --git a/client/adapters/opencodego.go b/client/adapters/opencodego.go index 6795b902..54f3ad1d 100644 --- a/client/adapters/opencodego.go +++ b/client/adapters/opencodego.go @@ -56,4 +56,3 @@ func (c *OpenCodeGoClient) Ping(ctx context.Context) error { } var _ core.Provider = (*OpenCodeGoClient)(nil) - 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 8c221696..fd260887 100644 --- a/config/deployment_env_sync_test.go +++ b/config/deployment_env_sync_test.go @@ -58,10 +58,10 @@ 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": {}, + "agnes-direct": {}, + "longcat-direct": {}, + "anthropic-direct": {}, + "openrouter": {}, } policy := BuildRoutingPolicyFromDeployments(deployments) for _, provider := range []string{"agnes", "longcat"} { diff --git a/config/providers.go b/config/providers.go index 941fd683..3c94714e 100644 --- a/config/providers.go +++ b/config/providers.go @@ -36,7 +36,7 @@ const ( DefaultMiniMaxOpenAIBaseURL = "https://api.minimax.io/v1" 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 + DefaultClinePassOpenAIBaseURL = "https://api.cline.bot/api/v1" // #nosec G101 -- public API base URL, not a secret value ) // ProviderTransport is the transport type for provider requests. From 16ef24747a1a989ca7a46cb550defff890e80977 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 22:18:42 +0530 Subject: [PATCH 17/18] fix: resolve golangci-lint issues for gocritic nilValReturn and unused transportError --- client/adapters/concentrate_responses.go | 2 +- client/adapters/test_helpers_test.go | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/client/adapters/concentrate_responses.go b/client/adapters/concentrate_responses.go index 4a6be3da..83cb4b13 100644 --- a/client/adapters/concentrate_responses.go +++ b/client/adapters/concentrate_responses.go @@ -357,7 +357,7 @@ func concentrateToolChoice(choice *core.ToolChoiceOption) interface{} { // See: https://concentrate.ai/docs/api-reference/endpoint/tool-calling func normalizeToolParams(params map[string]interface{}) map[string]interface{} { if params == nil { - return params + return nil } // Only enforce for object-typed schemas if t, ok := params["type"]; ok && t == "object" { diff --git a/client/adapters/test_helpers_test.go b/client/adapters/test_helpers_test.go index 4f7a2cb6..407d88bc 100644 --- a/client/adapters/test_helpers_test.go +++ b/client/adapters/test_helpers_test.go @@ -8,13 +8,6 @@ import ( "net/http" "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)) } From 885e8721c064796be8799e2ecf84e4502e487518 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 30 Jul 2026 22:27:21 +0530 Subject: [PATCH 18/18] style: format test_helpers_test.go with gofumpt --- client/adapters/test_helpers_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/client/adapters/test_helpers_test.go b/client/adapters/test_helpers_test.go index 407d88bc..1805eae4 100644 --- a/client/adapters/test_helpers_test.go +++ b/client/adapters/test_helpers_test.go @@ -8,6 +8,7 @@ import ( "net/http" "testing" ) + func testLogger(t *testing.T) *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }